Add notification actions: reply, explain screen, approve/deny

- New NotificationActionReceiver with 4 actions (reply via RemoteInput,
  screenshot+explain, approve all, deny all) using @EntryPoint for Hilt
- Rewrite CellClawService notification with live state observation via
  combine(agentLoop.state, approvalQueue.requests), dynamic Pause/Resume
  toggle, and conditional approve/deny buttons when approvals pending
- Register NotificationActionReceiver in AndroidManifest.xml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JordanTheJet
2026-02-20 16:37:50 -05:00
parent c0a4adc22b
commit 1fefde2cd7
3 changed files with 246 additions and 15 deletions

View File

@@ -160,6 +160,17 @@
</intent-filter>
</receiver>
<receiver
android:name=".service.receivers.NotificationActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.cellclaw.NOTIFICATION_REPLY" />
<action android:name="com.cellclaw.SCREENSHOT_EXPLAIN" />
<action android:name="com.cellclaw.APPROVE_ALL" />
<action android:name="com.cellclaw.DENY_ALL" />
</intent-filter>
</receiver>
<!-- File Provider for camera photos -->
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -7,19 +7,27 @@ import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.cellclaw.CellClawApp
import com.cellclaw.R
import com.cellclaw.agent.AgentLoop
import com.cellclaw.agent.AgentState
import com.cellclaw.approval.ApprovalQueue
import com.cellclaw.service.receivers.NotificationActionReceiver
import com.cellclaw.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
@AndroidEntryPoint
class CellClawService : Service() {
@Inject lateinit var agentLoop: AgentLoop
@Inject lateinit var approvalQueue: ApprovalQueue
private var wakeLock: PowerManager.WakeLock? = null
private var serviceScope: CoroutineScope? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -31,32 +39,58 @@ class CellClawService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, buildNotification("CellClaw is running"))
startForeground(NOTIFICATION_ID, buildNotification("CellClaw is running", AgentState.IDLE, false))
agentLoop.loadHistory()
observeState()
}
ACTION_STOP -> {
agentLoop.stop()
serviceScope?.cancel()
serviceScope = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
ACTION_PAUSE -> {
agentLoop.pause()
updateNotification("CellClaw is paused")
}
ACTION_RESUME -> {
agentLoop.resume()
updateNotification("CellClaw is running")
}
}
return START_STICKY
}
override fun onDestroy() {
serviceScope?.cancel()
serviceScope = null
releaseWakeLock()
super.onDestroy()
}
private fun buildNotification(text: String): Notification {
private fun observeState() {
serviceScope?.cancel()
serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
serviceScope?.launch {
combine(agentLoop.state, approvalQueue.requests) { state, requests ->
Pair(state, requests)
}.collect { (state, requests) ->
val hasPending = requests.isNotEmpty()
val text = when (state) {
AgentState.IDLE -> "Idle"
AgentState.THINKING -> "Thinking..."
AgentState.EXECUTING_TOOLS -> "Executing tools..."
AgentState.WAITING_APPROVAL -> "Waiting for approval (${requests.size} pending)"
AgentState.PAUSED -> "Paused"
AgentState.ERROR -> "Error occurred"
}
val notification = buildNotification(text, state, hasPending)
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
}
}
}
private fun buildNotification(text: String, state: AgentState, hasPendingApprovals: Boolean): Notification {
val openIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
@@ -69,28 +103,79 @@ class CellClawService : Service() {
PendingIntent.FLAG_IMMUTABLE
)
val pauseIntent = PendingIntent.getService(
this, 2,
Intent(this, CellClawService::class.java).apply { action = ACTION_PAUSE },
// Pause/Resume toggle
val isPaused = state == AgentState.PAUSED
val toggleAction = if (isPaused) {
PendingIntent.getService(
this, 2,
Intent(this, CellClawService::class.java).apply { action = ACTION_RESUME },
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getService(
this, 2,
Intent(this, CellClawService::class.java).apply { action = ACTION_PAUSE },
PendingIntent.FLAG_IMMUTABLE
)
}
// Reply action via RemoteInput (shows as text field, not a button)
val replyIntent = PendingIntent.getBroadcast(
this, 3,
Intent(this, NotificationActionReceiver::class.java).apply {
action = NotificationActionReceiver.ACTION_REPLY
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY)
.setLabel("Ask CellClaw...")
.build()
val replyAction = NotificationCompat.Action.Builder(0, "Reply", replyIntent)
.addRemoteInput(remoteInput)
.build()
// Explain Screen action
val explainIntent = PendingIntent.getBroadcast(
this, 4,
Intent(this, NotificationActionReceiver::class.java).apply {
action = NotificationActionReceiver.ACTION_SCREENSHOT_EXPLAIN
},
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CellClawApp.CHANNEL_SERVICE)
val builder = NotificationCompat.Builder(this, CellClawApp.CHANNEL_SERVICE)
.setContentTitle("CellClaw")
.setContentText(text)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(openIntent)
.addAction(0, "Stop", stopIntent)
.addAction(0, "Pause", pauseIntent)
.addAction(0, if (isPaused) "Resume" else "Pause", toggleAction)
.addAction(0, "Explain Screen", explainIntent)
.addAction(replyAction)
.setOngoing(true)
.setSilent(true)
.build()
}
private fun updateNotification(text: String) {
val notification = buildNotification(text)
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
// Approve/Deny actions in expanded area when approvals are pending
if (hasPendingApprovals) {
val approveIntent = PendingIntent.getBroadcast(
this, 5,
Intent(this, NotificationActionReceiver::class.java).apply {
action = NotificationActionReceiver.ACTION_APPROVE_ALL
},
PendingIntent.FLAG_IMMUTABLE
)
val denyIntent = PendingIntent.getBroadcast(
this, 6,
Intent(this, NotificationActionReceiver::class.java).apply {
action = NotificationActionReceiver.ACTION_DENY_ALL
},
PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(0, "Approve All", approveIntent)
builder.addAction(0, "Deny All", denyIntent)
}
return builder.build()
}
private fun acquireWakeLock() {

View File

@@ -0,0 +1,135 @@
package com.cellclaw.service.receivers
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.cellclaw.CellClawApp
import com.cellclaw.R
import com.cellclaw.agent.AgentLoop
import com.cellclaw.approval.ApprovalQueue
import com.cellclaw.approval.ApprovalResult
import com.cellclaw.tools.ScreenCaptureTool
import com.cellclaw.tools.VisionAnalyzeTool
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class NotificationActionReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ReceiverEntryPoint {
fun agentLoop(): AgentLoop
fun approvalQueue(): ApprovalQueue
fun screenCaptureTool(): ScreenCaptureTool
fun visionAnalyzeTool(): VisionAnalyzeTool
}
override fun onReceive(context: Context, intent: Intent) {
val entryPoint = EntryPointAccessors.fromApplication(
context.applicationContext, ReceiverEntryPoint::class.java
)
when (intent.action) {
ACTION_REPLY -> handleReply(context, intent, entryPoint)
ACTION_SCREENSHOT_EXPLAIN -> handleScreenshotExplain(context, entryPoint)
ACTION_APPROVE_ALL -> {
entryPoint.approvalQueue().respondAll(ApprovalResult.APPROVED)
Log.d(TAG, "Approved all pending requests")
}
ACTION_DENY_ALL -> {
entryPoint.approvalQueue().respondAll(ApprovalResult.DENIED)
Log.d(TAG, "Denied all pending requests")
}
}
}
private fun handleReply(context: Context, intent: Intent, entryPoint: ReceiverEntryPoint) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
val text = remoteInput?.getCharSequence(KEY_REPLY)?.toString()
if (text.isNullOrBlank()) return
Log.d(TAG, "Reply received: $text")
entryPoint.agentLoop().submitMessage(text)
}
private fun handleScreenshotExplain(context: Context, entryPoint: ReceiverEntryPoint) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val screenCapture = entryPoint.screenCaptureTool()
val visionAnalyze = entryPoint.visionAnalyzeTool()
val captureResult = screenCapture.execute(
buildJsonObject { put("include_base64", false) }
)
if (!captureResult.success) {
postResultNotification(context, "Screenshot failed: ${captureResult.error}")
return@launch
}
val filePath = captureResult.data?.let { data ->
(data as? kotlinx.serialization.json.JsonObject)?.get("file_path")
?.let { (it as? kotlinx.serialization.json.JsonPrimitive)?.content }
}
if (filePath == null) {
postResultNotification(context, "Screenshot failed: no file path")
return@launch
}
val analyzeResult = visionAnalyze.execute(buildJsonObject {
put("file_path", filePath)
put("question", "Describe what you see on the screen. What app is open? What is the user looking at?")
})
if (analyzeResult.success) {
val analysis = analyzeResult.data?.let { data ->
(data as? kotlinx.serialization.json.JsonObject)?.get("analysis")
?.let { (it as? kotlinx.serialization.json.JsonPrimitive)?.content }
} ?: "Analysis complete"
postResultNotification(context, analysis)
} else {
postResultNotification(context, "Analysis failed: ${analyzeResult.error}")
}
} catch (e: Exception) {
Log.e(TAG, "Screenshot explain failed: ${e.message}")
postResultNotification(context, "Error: ${e.message}")
} finally {
pendingResult.finish()
}
}
}
private fun postResultNotification(context: Context, text: String) {
val notification = NotificationCompat.Builder(context, CellClawApp.CHANNEL_ALERTS)
.setContentTitle("Screen Analysis")
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(EXPLAIN_NOTIFICATION_ID, notification)
}
companion object {
private const val TAG = "NotifActionReceiver"
const val ACTION_REPLY = "com.cellclaw.NOTIFICATION_REPLY"
const val ACTION_SCREENSHOT_EXPLAIN = "com.cellclaw.SCREENSHOT_EXPLAIN"
const val ACTION_APPROVE_ALL = "com.cellclaw.APPROVE_ALL"
const val ACTION_DENY_ALL = "com.cellclaw.DENY_ALL"
const val KEY_REPLY = "key_reply_text"
const val EXPLAIN_NOTIFICATION_ID = 100
}
}