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 e585e5643b6..709e9af5ec5 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 @@ -10,6 +10,11 @@ import android.service.notification.StatusBarNotification private const val MAX_NOTIFICATION_TEXT_CHARS = 512 +internal fun sanitizeNotificationText(value: CharSequence?): String? { + val normalized = value?.toString()?.trim().orEmpty() + return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null } +} + data class DeviceNotificationEntry( val key: String, val packageName: String, @@ -114,11 +119,11 @@ class DeviceNotificationListenerService : NotificationListenerService() { 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 title = sanitizeNotificationText(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)) + sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) + ?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT)) + val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) return DeviceNotificationEntry( key = keyValue, packageName = packageName, @@ -133,18 +138,6 @@ class DeviceNotificationListenerService : NotificationListenerService() { ) } - 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) @@ -155,8 +148,8 @@ class DeviceNotificationListenerService : NotificationListenerService() { return manager.isNotificationListenerAccessGranted(serviceComponent(context)) } - fun snapshot(context: Context): DeviceNotificationSnapshot { - return DeviceNotificationStore.snapshot(enabled = isAccessEnabled(context)) + fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot { + return DeviceNotificationStore.snapshot(enabled = enabled) } fun requestServiceRebind(context: Context) { 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 17123d93674..0216e19208c 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 @@ -7,51 +7,75 @@ 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", - ) - } +internal interface NotificationsStateProvider { + fun readSnapshot(context: Context): DeviceNotificationSnapshot - 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()) + fun requestServiceRebind(context: Context) +} + +private object SystemNotificationsStateProvider : NotificationsStateProvider { + override fun readSnapshot(context: Context): DeviceNotificationSnapshot { + val enabled = DeviceNotificationListenerService.isAccessEnabled(context) + if (!enabled) { + return DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ) + } + return DeviceNotificationListenerService.snapshot(context, enabled = true) + } + + override fun requestServiceRebind(context: Context) { + DeviceNotificationListenerService.requestServiceRebind(context) + } +} + +class NotificationsHandler private constructor( + private val appContext: Context, + private val stateProvider: NotificationsStateProvider, +) { + constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider) + + suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { + val snapshot = stateProvider.readSnapshot(appContext) + if (snapshot.enabled && !snapshot.connected) { + stateProvider.requestServiceRebind(appContext) + } + return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot)) + } + + private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String { + return 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)) } + } + }, + ), + ) + }.toString() + } + + companion object { + internal fun forTesting( + appContext: Context, + stateProvider: NotificationsStateProvider, + ): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider) } } 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 new file mode 100644 index 00000000000..7768e6e25da --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt @@ -0,0 +1,146 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class NotificationsHandlerTest { + @Test + fun notificationsListReturnsStatusPayloadWhenDisabled() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertFalse(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(0, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, payload.getValue("notifications").jsonArray.size) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun notificationsListRequestsRebindWhenEnabledButDisconnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = false, + notifications = listOf(sampleEntry("n1")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(1, payload.getValue("notifications").jsonArray.size) + assertEquals(1, provider.rebindRequests) + } + + @Test + fun notificationsListDoesNotRequestRebindWhenConnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertTrue(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun sanitizeNotificationTextReturnsNullForBlankInput() { + assertNull(sanitizeNotificationText(null)) + assertNull(sanitizeNotificationText(" ")) + } + + @Test + fun sanitizeNotificationTextTrimsAndTruncates() { + val value = " ${"x".repeat(600)} " + val sanitized = sanitizeNotificationText(value) + + assertEquals(512, sanitized?.length) + assertTrue((sanitized ?: "").all { it == 'x' }) + } + + private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject { + val payloadJson = result.payloadJson ?: error("expected payload") + return Json.parseToJsonElement(payloadJson).jsonObject + } + + private fun appContext(): Context = RuntimeEnvironment.getApplication() + + private fun sampleEntry(key: String): DeviceNotificationEntry = + DeviceNotificationEntry( + key = key, + packageName = "com.example.app", + title = "Title", + text = "Text", + subText = null, + category = null, + channelId = null, + postTimeMs = 123L, + isOngoing = false, + isClearable = true, + ) +} + +private class FakeNotificationsStateProvider( + private val snapshot: DeviceNotificationSnapshot, +) : NotificationsStateProvider { + var rebindRequests: Int = 0 + private set + + override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot + + override fun requestServiceRebind(context: Context) { + rebindRequests += 1 + } +}