From cf4fe4195767f7bff4b4af73a8a918f9dc187ffa Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:02:39 +0530 Subject: [PATCH] feat(android): add notifications.list node command --- apps/android/app/src/main/AndroidManifest.xml | 9 + .../java/ai/openclaw/android/NodeRuntime.kt | 5 + .../node/DeviceNotificationListenerService.kt | 171 ++++++++++++++++++ .../android/node/InvokeCommandRegistry.kt | 4 + .../openclaw/android/node/InvokeDispatcher.kt | 5 + .../android/node/NotificationsHandler.kt | 57 ++++++ .../protocol/OpenClawProtocolConstants.kt | 9 + .../android/node/InvokeCommandRegistryTest.kt | 3 + .../protocol/OpenClawProtocolConstantsTest.kt | 5 + 9 files changed, 268 insertions(+) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 6b8dd7eedba..3d0b27f39e6 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,15 @@ android:name=".NodeForegroundService" android:exported="false" android:foregroundServiceType="dataSync|microphone|mediaProjection" /> + + + + + , +) + +private object DeviceNotificationStore { + private val lock = Any() + private var connected = false + private val byKey = LinkedHashMap() + + fun replace(entries: List) { + synchronized(lock) { + byKey.clear() + for (entry in entries) { + byKey[entry.key] = entry + } + } + } + + fun upsert(entry: DeviceNotificationEntry) { + synchronized(lock) { + byKey[entry.key] = entry + } + } + + fun remove(key: String) { + synchronized(lock) { + byKey.remove(key) + } + } + + fun setConnected(value: Boolean) { + synchronized(lock) { + connected = value + if (!value) { + byKey.clear() + } + } + } + + fun snapshot(enabled: Boolean): DeviceNotificationSnapshot { + val (isConnected, entries) = + synchronized(lock) { + connected to byKey.values.sortedByDescending { it.postTimeMs } + } + return DeviceNotificationSnapshot( + enabled = enabled, + connected = isConnected, + notifications = entries, + ) + } +} + +class DeviceNotificationListenerService : NotificationListenerService() { + override fun onListenerConnected() { + super.onListenerConnected() + DeviceNotificationStore.setConnected(true) + refreshActiveNotifications() + } + + override fun onListenerDisconnected() { + DeviceNotificationStore.setConnected(false) + super.onListenerDisconnected() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + super.onNotificationPosted(sbn) + val entry = sbn?.toEntry() ?: return + DeviceNotificationStore.upsert(entry) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification?) { + super.onNotificationRemoved(sbn) + val key = sbn?.key ?: return + DeviceNotificationStore.remove(key) + } + + private fun refreshActiveNotifications() { + val entries = + runCatching { + activeNotifications + ?.mapNotNull { it.toEntry() } + ?: emptyList() + }.getOrElse { emptyList() } + DeviceNotificationStore.replace(entries) + } + + private fun StatusBarNotification.toEntry(): DeviceNotificationEntry { + val extras = notification.extras + val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime" + val title = sanitizeText(extras?.getCharSequence(Notification.EXTRA_TITLE)) + val body = + sanitizeText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) + ?: sanitizeText(extras?.getCharSequence(Notification.EXTRA_TEXT)) + val subText = sanitizeText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) + return DeviceNotificationEntry( + key = keyValue, + packageName = packageName, + title = title, + text = body, + subText = subText, + category = notification.category?.trim()?.ifEmpty { null }, + channelId = notification.channelId?.trim()?.ifEmpty { null }, + postTimeMs = postTime, + isOngoing = isOngoing, + isClearable = isClearable, + ) + } + + private fun sanitizeText(value: CharSequence?): String? { + val normalized = value?.toString()?.trim().orEmpty() + if (normalized.isEmpty()) { + return null + } + return if (normalized.length <= MAX_NOTIFICATION_TEXT_CHARS) { + normalized + } else { + normalized.take(MAX_NOTIFICATION_TEXT_CHARS) + } + } + + companion object { + private fun serviceComponent(context: Context): ComponentName { + return ComponentName(context, DeviceNotificationListenerService::class.java) + } + + fun isAccessEnabled(context: Context): Boolean { + val manager = context.getSystemService(NotificationManager::class.java) ?: return false + return manager.isNotificationListenerAccessGranted(serviceComponent(context)) + } + + fun snapshot(context: Context): DeviceNotificationSnapshot { + return DeviceNotificationStore.snapshot(enabled = isAccessEnabled(context)) + } + + fun requestServiceRebind(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + runCatching { + NotificationListenerService.requestRebind(serviceComponent(context)) + } + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt index 812ecf2ba4e..ce87525904f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt @@ -4,6 +4,7 @@ import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand import ai.openclaw.android.protocol.OpenClawCanvasCommand import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand @@ -74,6 +75,9 @@ object InvokeCommandRegistry { name = OpenClawLocationCommand.Get.rawValue, availability = InvokeCommandAvailability.LocationEnabled, ), + InvokeCommandSpec( + name = OpenClawNotificationsCommand.List.rawValue, + ), InvokeCommandSpec( name = OpenClawSmsCommand.Send.rawValue, availability = InvokeCommandAvailability.SmsAvailable, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index d293df76668..936ad7b3d11 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -5,6 +5,7 @@ import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand import ai.openclaw.android.protocol.OpenClawCanvasCommand import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand @@ -12,6 +13,7 @@ class InvokeDispatcher( private val canvas: CanvasController, private val cameraHandler: CameraHandler, private val locationHandler: LocationHandler, + private val notificationsHandler: NotificationsHandler, private val screenHandler: ScreenHandler, private val smsHandler: SmsHandler, private val a2uiHandler: A2UIHandler, @@ -114,6 +116,9 @@ class InvokeDispatcher( // Location command OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson) + // Notifications command + OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson) + // Screen command OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) 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 new file mode 100644 index 00000000000..17123d93674 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -0,0 +1,57 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class NotificationsHandler( + private val appContext: Context, +) { + suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { + if (!DeviceNotificationListenerService.isAccessEnabled(appContext)) { + return GatewaySession.InvokeResult.error( + code = "NOTIFICATION_LISTENER_DISABLED", + message = + "NOTIFICATION_LISTENER_DISABLED: enable Notification Access for OpenClaw in system settings", + ) + } + val snapshot = DeviceNotificationListenerService.snapshot(appContext) + if (!snapshot.connected) { + DeviceNotificationListenerService.requestServiceRebind(appContext) + return GatewaySession.InvokeResult.error( + code = "NOTIFICATION_LISTENER_UNAVAILABLE", + message = "NOTIFICATION_LISTENER_UNAVAILABLE: listener is reconnecting; retry shortly", + ) + } + + val payload = + buildJsonObject { + put("enabled", JsonPrimitive(snapshot.enabled)) + put("connected", JsonPrimitive(snapshot.connected)) + put("count", JsonPrimitive(snapshot.notifications.size)) + put( + "notifications", + JsonArray( + snapshot.notifications.map { entry -> + buildJsonObject { + put("key", JsonPrimitive(entry.key)) + put("packageName", JsonPrimitive(entry.packageName)) + put("postTimeMs", JsonPrimitive(entry.postTimeMs)) + put("isOngoing", JsonPrimitive(entry.isOngoing)) + put("isClearable", JsonPrimitive(entry.isClearable)) + entry.title?.let { put("title", JsonPrimitive(it)) } + entry.text?.let { put("text", JsonPrimitive(it)) } + entry.subText?.let { put("subText", JsonPrimitive(it)) } + entry.category?.let { put("category", JsonPrimitive(it)) } + entry.channelId?.let { put("channelId", JsonPrimitive(it)) } + } + }, + ), + ) + } + return GatewaySession.InvokeResult.ok(payload.toString()) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt index ccca40c4c35..d73c61d233b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt @@ -69,3 +69,12 @@ enum class OpenClawLocationCommand(val rawValue: String) { const val NamespacePrefix: String = "location." } } + +enum class OpenClawNotificationsCommand(val rawValue: String) { + List("notifications.list"), + ; + + companion object { + const val NamespacePrefix: String = "notifications." + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt index 65b18656708..88795b0d9ce 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -2,6 +2,7 @@ package ai.openclaw.android.node import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawSmsCommand import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,6 +22,7 @@ class InvokeCommandRegistryTest { assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue)) assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue)) assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue)) assertFalse(commands.contains("debug.logs")) assertFalse(commands.contains("debug.ed25519")) @@ -40,6 +42,7 @@ class InvokeCommandRegistryTest { assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue)) assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue)) assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue)) assertTrue(commands.contains("debug.logs")) assertTrue(commands.contains("debug.ed25519")) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt index 10ab733ae53..71eec189509 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt @@ -32,4 +32,9 @@ class OpenClawProtocolConstantsTest { fun screenCommandsUseStableStrings() { assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) } + + @Test + fun notificationsCommandsUseStableStrings() { + assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue) + } }