diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 6ad35f02dfe..3e8548c5df4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -303,6 +303,14 @@ class NodeRuntime(context: Context) { }, ) + init { + DeviceNotificationListenerService.setNodeEventSink { event, payloadJson -> + scope.launch { + nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson) + } + } + } + private val chat: ChatController = ChatController( scope = scope, 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 1eff015c5f0..ecc5cee433d 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 @@ -9,8 +9,12 @@ import android.content.Intent import android.os.Build import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put private const val MAX_NOTIFICATION_TEXT_CHARS = 512 +private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed" internal fun sanitizeNotificationText(value: CharSequence?): String? { val normalized = value?.toString()?.trim().orEmpty() @@ -133,12 +137,41 @@ class DeviceNotificationListenerService : NotificationListenerService() { super.onNotificationPosted(sbn) val entry = sbn?.toEntry() ?: return DeviceNotificationStore.upsert(entry) + emitNotificationsChanged( + buildJsonObject { + put("change", JsonPrimitive("posted")) + 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(), + ) } override fun onNotificationRemoved(sbn: StatusBarNotification?) { super.onNotificationRemoved(sbn) - val key = sbn?.key ?: return + val removed = sbn ?: return + val key = removed.key.trim() + if (key.isEmpty()) { + return + } DeviceNotificationStore.remove(key) + emitNotificationsChanged( + buildJsonObject { + put("change", JsonPrimitive("removed")) + put("key", JsonPrimitive(key)) + val packageName = removed.packageName.trim() + if (packageName.isNotEmpty()) { + put("packageName", JsonPrimitive(packageName)) + } + }.toString(), + ) } private fun refreshActiveNotifications() { @@ -175,11 +208,16 @@ class DeviceNotificationListenerService : NotificationListenerService() { companion object { @Volatile private var activeService: DeviceNotificationListenerService? = null + @Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null private fun serviceComponent(context: Context): ComponentName { return ComponentName(context, DeviceNotificationListenerService::class.java) } + fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) { + nodeEventSink = sink + } + fun isAccessEnabled(context: Context): Boolean { val manager = context.getSystemService(NotificationManager::class.java) ?: return false return manager.isNotificationListenerAccessGranted(serviceComponent(context)) @@ -214,6 +252,12 @@ class DeviceNotificationListenerService : NotificationListenerService() { ) return service.executeActionInternal(request) } + + private fun emitNotificationsChanged(payloadJson: String) { + runCatching { + nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson) + } + } } private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult { diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index bc9396c9567..e672b05d357 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -347,7 +347,7 @@ describe("resolveNodeCommandAllowlist", () => { expect(allow.has("notifications.actions")).toBe(true); expect(allow.has("device.permissions")).toBe(true); expect(allow.has("device.health")).toBe(true); - expect(allow.has("system.notify")).toBe(false); + expect(allow.has("system.notify")).toBe(true); }); it("can explicitly allow dangerous commands via allowCommands", () => { diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 39390329de7..01d1b920c7f 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -82,6 +82,7 @@ const PLATFORM_DEFAULTS: Record = { ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, ...ANDROID_NOTIFICATION_COMMANDS, + NODE_SYSTEM_NOTIFY_COMMAND, ...ANDROID_DEVICE_COMMANDS, ...CONTACTS_COMMANDS, ...CALENDAR_COMMANDS, diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index a7c0b1057fc..9af43490e4e 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -351,6 +351,64 @@ describe("voice transcript events", () => { }); }); +describe("notifications changed events", () => { + beforeEach(() => { + enqueueSystemEventMock.mockClear(); + requestHeartbeatNowMock.mockClear(); + }); + + it("enqueues notifications.changed posted events", async () => { + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-n1", { + event: "notifications.changed", + payloadJSON: JSON.stringify({ + change: "posted", + key: "notif-1", + packageName: "com.example.chat", + title: "Message", + text: "Ping from Alex", + }), + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + "Notification posted (node=node-n1 key=notif-1 package=com.example.chat): Message - Ping from Alex", + { sessionKey: "node-node-n1", contextKey: "notification:notif-1" }, + ); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "notifications-event" }); + }); + + it("enqueues notifications.changed removed events", async () => { + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-n2", { + event: "notifications.changed", + payloadJSON: JSON.stringify({ + change: "removed", + key: "notif-2", + packageName: "com.example.mail", + }), + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + "Notification removed (node=node-n2 key=notif-2 package=com.example.mail)", + { sessionKey: "node-node-n2", contextKey: "notification:notif-2" }, + ); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "notifications-event" }); + }); + + it("ignores notifications.changed payloads missing required fields", async () => { + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-n3", { + event: "notifications.changed", + payloadJSON: JSON.stringify({ + change: "posted", + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); +}); + describe("agent request events", () => { beforeEach(() => { agentCommandMock.mockClear(); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index c191a836066..53b34132671 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -23,6 +23,7 @@ import { import { formatForLog } from "./ws-log.js"; const MAX_EXEC_EVENT_OUTPUT_CHARS = 180; +const MAX_NOTIFICATION_EVENT_TEXT_CHARS = 120; const VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS = 1500; const MAX_RECENT_VOICE_TRANSCRIPTS = 200; @@ -122,6 +123,18 @@ function compactExecEventOutput(raw: string) { return `${normalized.slice(0, safe)}…`; } +function compactNotificationEventText(raw: string) { + const normalized = raw.replace(/\s+/g, " ").trim(); + if (!normalized) { + return ""; + } + if (normalized.length <= MAX_NOTIFICATION_EVENT_TEXT_CHARS) { + return normalized; + } + const safe = Math.max(1, MAX_NOTIFICATION_EVENT_TEXT_CHARS - 1); + return `${normalized.slice(0, safe)}…`; +} + type LoadedSessionEntry = ReturnType; async function touchSessionStore(params: { @@ -441,6 +454,40 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt }); return; } + case "notifications.changed": { + const obj = parsePayloadObject(evt.payloadJSON); + if (!obj) { + return; + } + const change = normalizeNonEmptyString(obj.change)?.toLowerCase(); + if (change !== "posted" && change !== "removed") { + return; + } + const key = normalizeNonEmptyString(obj.key); + if (!key) { + return; + } + const sessionKey = normalizeNonEmptyString(obj.sessionKey) ?? `node-${nodeId}`; + const packageName = normalizeNonEmptyString(obj.packageName); + const title = compactNotificationEventText(normalizeNonEmptyString(obj.title) ?? ""); + const text = compactNotificationEventText(normalizeNonEmptyString(obj.text) ?? ""); + + let summary = `Notification ${change} (node=${nodeId} key=${key}`; + if (packageName) { + summary += ` package=${packageName}`; + } + summary += ")"; + if (change === "posted") { + const messageParts = [title, text].filter(Boolean); + if (messageParts.length > 0) { + summary += `: ${messageParts.join(" - ")}`; + } + } + + enqueueSystemEvent(summary, { sessionKey, contextKey: `notification:${key}` }); + requestHeartbeatNow({ reason: "notifications-event" }); + return; + } case "chat.subscribe": { if (!evt.payloadJSON) { return;