diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt index 896d7c7c74c..814cf382d90 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt @@ -1,8 +1,11 @@ package ai.openclaw.android.node +import android.Manifest +import android.app.ActivityManager import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager @@ -11,6 +14,7 @@ import android.os.Environment import android.os.PowerManager import android.os.StatFs import android.os.SystemClock +import androidx.core.content.ContextCompat import ai.openclaw.android.BuildConfig import ai.openclaw.android.gateway.GatewaySession import java.util.Locale @@ -30,6 +34,14 @@ class DeviceHandler( return GatewaySession.InvokeResult.ok(infoPayloadJson()) } + fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult { + return GatewaySession.InvokeResult.ok(permissionsPayloadJson()) + } + + fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult { + return GatewaySession.InvokeResult.ok(healthPayloadJson()) + } + private fun statusPayloadJson(): String { val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val batteryStatus = @@ -112,6 +124,144 @@ class DeviceHandler( }.toString() } + private fun permissionsPayloadJson(): String { + val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext) + return buildJsonObject { + put( + "permissions", + buildJsonObject { + put( + "camera", + permissionStateJson( + granted = hasPermission(Manifest.permission.CAMERA), + promptableWhenDenied = true, + ), + ) + put( + "microphone", + permissionStateJson( + granted = hasPermission(Manifest.permission.RECORD_AUDIO), + promptableWhenDenied = true, + ), + ) + put( + "location", + permissionStateJson( + granted = + hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) || + hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION), + promptableWhenDenied = true, + ), + ) + put( + "backgroundLocation", + permissionStateJson( + granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + promptableWhenDenied = true, + ), + ) + put( + "sms", + permissionStateJson( + granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms, + promptableWhenDenied = canSendSms, + ), + ) + put( + "notificationListener", + permissionStateJson( + granted = notificationAccess, + promptableWhenDenied = true, + ), + ) + // Screen capture on Android is interactive per-capture consent, not a sticky app permission. + put( + "screenCapture", + permissionStateJson( + granted = false, + promptableWhenDenied = true, + ), + ) + }, + ) + }.toString() + } + + private fun healthPayloadJson(): String { + val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val batteryStatus = + batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + ?: BatteryManager.BATTERY_STATUS_UNKNOWN + val batteryPlugged = batteryIntent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0 + val batteryTempTenths = batteryIntent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE) + ?: Int.MIN_VALUE + val batteryTempC = + if (batteryTempTenths == Int.MIN_VALUE) { + null + } else { + batteryTempTenths.toDouble() / 10.0 + } + + val batteryManager = appContext.getSystemService(BatteryManager::class.java) + val currentNowUa = batteryManager?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) + val currentNowMa = + if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) { + null + } else { + currentNowUa.toDouble() / 1_000.0 + } + + val powerManager = appContext.getSystemService(PowerManager::class.java) + val activityManager = appContext.getSystemService(ActivityManager::class.java) + val memoryInfo = ActivityManager.MemoryInfo() + activityManager?.getMemoryInfo(memoryInfo) + val totalRamBytes = memoryInfo.totalMem.coerceAtLeast(0L) + val availableRamBytes = memoryInfo.availMem.coerceAtLeast(0L) + val usedRamBytes = (totalRamBytes - availableRamBytes).coerceAtLeast(0L) + val lowMemory = memoryInfo.lowMemory + val memoryPressure = mapMemoryPressure(totalRamBytes, availableRamBytes, lowMemory) + + return buildJsonObject { + put( + "memory", + buildJsonObject { + put("pressure", JsonPrimitive(memoryPressure)) + put("totalRamBytes", JsonPrimitive(totalRamBytes)) + put("availableRamBytes", JsonPrimitive(availableRamBytes)) + put("usedRamBytes", JsonPrimitive(usedRamBytes)) + put("thresholdBytes", JsonPrimitive(memoryInfo.threshold.coerceAtLeast(0L))) + put("lowMemory", JsonPrimitive(lowMemory)) + }, + ) + put( + "battery", + buildJsonObject { + put("state", JsonPrimitive(mapBatteryState(batteryStatus))) + put("chargingType", JsonPrimitive(mapChargingType(batteryPlugged))) + batteryTempC?.let { put("temperatureC", JsonPrimitive(it)) } + currentNowMa?.let { put("currentMa", JsonPrimitive(it)) } + }, + ) + put( + "power", + buildJsonObject { + put("dozeModeEnabled", JsonPrimitive(powerManager?.isDeviceIdleMode == true)) + put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true)) + }, + ) + put( + "system", + buildJsonObject { + Build.VERSION.SECURITY_PATCH + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { put("securityPatchLevel", JsonPrimitive(it)) } + }, + ) + }.toString() + } + private fun batteryLevelFraction(intent: Intent?): Double? { val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 @@ -128,6 +278,16 @@ class DeviceHandler( } } + private fun mapChargingType(plugged: Int): String { + return when (plugged) { + BatteryManager.BATTERY_PLUGGED_AC -> "ac" + BatteryManager.BATTERY_PLUGGED_USB -> "usb" + BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless" + BatteryManager.BATTERY_PLUGGED_DOCK -> "dock" + else -> "none" + } + } + private fun mapThermalState(powerManager: PowerManager?): String { val thermal = powerManager?.currentThermalStatus ?: return "nominal" return when (thermal) { @@ -150,6 +310,30 @@ class DeviceHandler( } } + private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) = + buildJsonObject { + put("status", JsonPrimitive(if (granted) "granted" else "denied")) + put("promptable", JsonPrimitive(!granted && promptableWhenDenied)) + } + + private fun hasPermission(permission: String): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED + ) + } + + private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String { + if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown" + if (lowMemory) return "critical" + val freeRatio = availableBytes.toDouble() / totalBytes.toDouble() + return when { + freeRatio <= 0.05 -> "critical" + freeRatio <= 0.15 -> "high" + freeRatio <= 0.30 -> "moderate" + else -> "normal" + } + } + private fun networkInterfacesJson(caps: NetworkCapabilities?) = buildJsonArray { if (caps == null) return@buildJsonArray diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt index 709e9af5ec5..32ca08764fe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt @@ -2,8 +2,10 @@ package ai.openclaw.android.node import android.app.Notification import android.app.NotificationManager +import android.app.RemoteInput import android.content.ComponentName import android.content.Context +import android.content.Intent import android.os.Build import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification @@ -34,6 +36,24 @@ data class DeviceNotificationSnapshot( val notifications: List, ) +enum class NotificationActionKind { + Open, + Dismiss, + Reply, +} + +data class NotificationActionRequest( + val key: String, + val kind: NotificationActionKind, + val replyText: String? = null, +) + +data class NotificationActionResult( + val ok: Boolean, + val code: String? = null, + val message: String? = null, +) + private object DeviceNotificationStore { private val lock = Any() private var connected = false @@ -85,15 +105,26 @@ private object DeviceNotificationStore { class DeviceNotificationListenerService : NotificationListenerService() { override fun onListenerConnected() { super.onListenerConnected() + activeService = this DeviceNotificationStore.setConnected(true) refreshActiveNotifications() } override fun onListenerDisconnected() { + if (activeService === this) { + activeService = null + } DeviceNotificationStore.setConnected(false) super.onListenerDisconnected() } + override fun onDestroy() { + if (activeService === this) { + activeService = null + } + super.onDestroy() + } + override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) val entry = sbn?.toEntry() ?: return @@ -139,6 +170,8 @@ class DeviceNotificationListenerService : NotificationListenerService() { } companion object { + @Volatile private var activeService: DeviceNotificationListenerService? = null + private fun serviceComponent(context: Context): ComponentName { return ComponentName(context, DeviceNotificationListenerService::class.java) } @@ -160,5 +193,119 @@ class DeviceNotificationListenerService : NotificationListenerService() { NotificationListenerService.requestRebind(serviceComponent(context)) } } + + fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult { + if (!isAccessEnabled(context)) { + return NotificationActionResult( + ok = false, + code = "NOTIFICATIONS_DISABLED", + message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings", + ) + } + val service = activeService + ?: return NotificationActionResult( + ok = false, + code = "NOTIFICATIONS_UNAVAILABLE", + message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected", + ) + return service.executeActionInternal(request) + } + } + + private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult { + val sbn = + activeNotifications + ?.firstOrNull { it.key == request.key } + ?: return NotificationActionResult( + ok = false, + code = "NOTIFICATION_NOT_FOUND", + message = "NOTIFICATION_NOT_FOUND: notification key not found", + ) + if (!sbn.isClearable) { + return NotificationActionResult( + ok = false, + code = "NOTIFICATION_NOT_CLEARABLE", + message = "NOTIFICATION_NOT_CLEARABLE: notification is ongoing or protected", + ) + } + + return when (request.kind) { + NotificationActionKind.Open -> { + val pendingIntent = sbn.notification.contentIntent + ?: return NotificationActionResult( + ok = false, + code = "ACTION_UNAVAILABLE", + message = "ACTION_UNAVAILABLE: notification has no open action", + ) + runCatching { + pendingIntent.send() + }.fold( + onSuccess = { NotificationActionResult(ok = true) }, + onFailure = { err -> + NotificationActionResult( + ok = false, + code = "ACTION_FAILED", + message = "ACTION_FAILED: ${err.message ?: "open failed"}", + ) + }, + ) + } + + NotificationActionKind.Dismiss -> { + runCatching { + cancelNotification(sbn.key) + DeviceNotificationStore.remove(sbn.key) + }.fold( + onSuccess = { NotificationActionResult(ok = true) }, + onFailure = { err -> + NotificationActionResult( + ok = false, + code = "ACTION_FAILED", + message = "ACTION_FAILED: ${err.message ?: "dismiss failed"}", + ) + }, + ) + } + + NotificationActionKind.Reply -> { + val replyText = request.replyText?.trim().orEmpty() + if (replyText.isEmpty()) { + return NotificationActionResult( + ok = false, + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: replyText required for reply action", + ) + } + val action = + sbn.notification.actions + ?.firstOrNull { candidate -> + candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty() + } + ?: return NotificationActionResult( + ok = false, + code = "ACTION_UNAVAILABLE", + message = "ACTION_UNAVAILABLE: notification has no reply action", + ) + val remoteInputs = action.remoteInputs ?: emptyArray() + val fillInIntent = Intent() + val replyBundle = android.os.Bundle() + for (remoteInput in remoteInputs) { + replyBundle.putCharSequence(remoteInput.resultKey, replyText) + } + RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, replyBundle) + runCatching { + action.actionIntent.send(this, 0, fillInIntent) + }.fold( + onSuccess = { NotificationActionResult(ok = true) }, + onFailure = { err -> + NotificationActionResult( + ok = false, + code = "ACTION_FAILED", + message = "ACTION_FAILED: ${err.message ?: "reply failed"}", + ) + }, + ) + } + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt index 0216e19208c..a7d757d4d48 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -2,15 +2,20 @@ package ai.openclaw.android.node import android.content.Context import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put internal interface NotificationsStateProvider { fun readSnapshot(context: Context): DeviceNotificationSnapshot fun requestServiceRebind(context: Context) + + fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult } private object SystemNotificationsStateProvider : NotificationsStateProvider { @@ -29,6 +34,10 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider { override fun requestServiceRebind(context: Context) { DeviceNotificationListenerService.requestServiceRebind(context) } + + override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult { + return DeviceNotificationListenerService.executeAction(context, request) + } } class NotificationsHandler private constructor( @@ -45,6 +54,68 @@ class NotificationsHandler private constructor( return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot)) } + suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult { + val params = parseParamsObject(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: expected JSON object", + ) + val key = + readString(params, "key") + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: key required", + ) + val actionRaw = + readString(params, "action")?.lowercase() + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: action required (open|dismiss|reply)", + ) + val action = + when (actionRaw) { + "open" -> NotificationActionKind.Open + "dismiss" -> NotificationActionKind.Dismiss + "reply" -> NotificationActionKind.Reply + else -> + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: action must be open|dismiss|reply", + ) + } + val replyText = readString(params, "replyText") + if (action == NotificationActionKind.Reply && replyText.isNullOrBlank()) { + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: replyText required for reply action", + ) + } + + val result = + stateProvider.executeAction( + appContext, + NotificationActionRequest( + key = key, + kind = action, + replyText = replyText, + ), + ) + if (!result.ok) { + return GatewaySession.InvokeResult.error( + code = result.code ?: "UNAVAILABLE", + message = result.message ?: "notification action failed", + ) + } + + val payload = + buildJsonObject { + put("ok", JsonPrimitive(true)) + put("key", JsonPrimitive(key)) + put("action", JsonPrimitive(actionRaw)) + }.toString() + return GatewaySession.InvokeResult.ok(payload) + } + private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String { return buildJsonObject { put("enabled", JsonPrimitive(snapshot.enabled)) @@ -72,6 +143,21 @@ class NotificationsHandler private constructor( }.toString() } + private fun parseParamsObject(paramsJson: String?): JsonObject? { + if (paramsJson.isNullOrBlank()) return null + return try { + Json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } + } + + private fun readString(params: JsonObject, key: String): String? = + (params[key] as? JsonPrimitive) + ?.contentOrNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + companion object { internal fun forTesting( appContext: Context, diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt index 046f610bf5b..c3c97ee15cd 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt @@ -73,6 +73,68 @@ class DeviceHandlerTest { assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0) } + @Test + fun handleDevicePermissions_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDevicePermissions(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val permissions = payload.getValue("permissions").jsonObject + val expected = + listOf( + "camera", + "microphone", + "location", + "backgroundLocation", + "sms", + "notificationListener", + "screenCapture", + ) + for (key in expected) { + val state = permissions.getValue(key).jsonObject + val status = state.getValue("status").jsonPrimitive.content + assertTrue(status == "granted" || status == "denied") + state.getValue("promptable").jsonPrimitive.boolean + } + } + + @Test + fun handleDeviceHealth_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceHealth(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val memory = payload.getValue("memory").jsonObject + val battery = payload.getValue("battery").jsonObject + val power = payload.getValue("power").jsonObject + val system = payload.getValue("system").jsonObject + + val pressure = memory.getValue("pressure").jsonPrimitive.content + assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown")) + val totalRamBytes = memory.getValue("totalRamBytes").jsonPrimitive.content.toLong() + val availableRamBytes = memory.getValue("availableRamBytes").jsonPrimitive.content.toLong() + val usedRamBytes = memory.getValue("usedRamBytes").jsonPrimitive.content.toLong() + assertTrue(totalRamBytes >= 0L) + assertTrue(availableRamBytes >= 0L) + assertTrue(usedRamBytes >= 0L) + memory.getValue("lowMemory").jsonPrimitive.boolean + + val batteryState = battery.getValue("state").jsonPrimitive.content + assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full")) + val chargingType = battery.getValue("chargingType").jsonPrimitive.content + assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock")) + battery["temperatureC"]?.jsonPrimitive?.double + battery["currentMa"]?.jsonPrimitive?.double + + power.getValue("dozeModeEnabled").jsonPrimitive.boolean + power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean + system["securityPatchLevel"]?.jsonPrimitive?.content + } + private fun appContext(): Context = RuntimeEnvironment.getApplication() private fun parsePayload(payloadJson: String?): JsonObject { diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt index 7768e6e25da..612c9d518aa 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt @@ -95,6 +95,78 @@ class NotificationsHandlerTest { assertEquals(0, provider.rebindRequests) } + @Test + fun notificationsActions_executesDismissAction() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""") + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("ok").jsonPrimitive.boolean) + assertEquals("n2", payload.getValue("key").jsonPrimitive.content) + assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content) + assertEquals("n2", provider.lastAction?.key) + assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind) + } + + @Test + fun notificationsActions_requiresReplyTextForReplyAction() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n3")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n3","action":"reply"}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + assertEquals(0, provider.actionRequests) + } + + @Test + fun notificationsActions_propagatesProviderError() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n4")), + ), + ).also { + it.actionResult = + NotificationActionResult( + ok = false, + code = "NOTIFICATION_NOT_FOUND", + message = "NOTIFICATION_NOT_FOUND: notification key not found", + ) + } + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""") + + assertFalse(result.ok) + assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code) + assertEquals(1, provider.actionRequests) + } + @Test fun sanitizeNotificationTextReturnsNullForBlankInput() { assertNull(sanitizeNotificationText(null)) @@ -137,10 +209,23 @@ private class FakeNotificationsStateProvider( ) : NotificationsStateProvider { var rebindRequests: Int = 0 private set + var actionRequests: Int = 0 + private set + var actionResult: NotificationActionResult = NotificationActionResult(ok = true) + var lastAction: NotificationActionRequest? = null override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot override fun requestServiceRebind(context: Context) { rebindRequests += 1 } + + override fun executeAction( + context: Context, + request: NotificationActionRequest, + ): NotificationActionResult { + actionRequests += 1 + lastAction = request + return actionResult + } }