diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 6b8dd7eed..3d0b27f39 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 812ecf2ba..ce8752590 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 d293df766..936ad7b3d 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 000000000..17123d936
--- /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 ccca40c4c..d73c61d23 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 65b186567..88795b0d9 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 10ab733ae..71eec1895 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)
+ }
}