mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(android): unify notifications.list status flow
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user