feat(android): add notifications.list node command

This commit is contained in:
Ayaan Zaidi
2026-02-26 14:02:39 +05:30
committed by Ayaan Zaidi
parent c289b5ff9f
commit cf4fe41957
9 changed files with 268 additions and 0 deletions

View File

@@ -38,6 +38,15 @@
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -92,6 +92,10 @@ class NodeRuntime(context: Context) {
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
@@ -123,6 +127,7 @@ class NodeRuntime(context: Context) {
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
notificationsHandler = notificationsHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,

View File

@@ -0,0 +1,171 @@
package ai.openclaw.android.node
import android.app.Notification
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
data class DeviceNotificationEntry(
val key: String,
val packageName: String,
val title: String?,
val text: String?,
val subText: String?,
val category: String?,
val channelId: String?,
val postTimeMs: Long,
val isOngoing: Boolean,
val isClearable: Boolean,
)
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
val notifications: List<DeviceNotificationEntry>,
)
private object DeviceNotificationStore {
private val lock = Any()
private var connected = false
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
fun replace(entries: List<DeviceNotificationEntry>) {
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))
}
}
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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."
}
}

View File

@@ -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"))

View File

@@ -32,4 +32,9 @@ class OpenClawProtocolConstantsTest {
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
}
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
}
}