feat(gateway): enable Android notify + notification events

This commit is contained in:
Ayaan Zaidi
2026-02-28 10:11:54 +05:30
committed by Ayaan Zaidi
parent 5350f5b035
commit 9d3ccf4754
6 changed files with 160 additions and 2 deletions

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -82,6 +82,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...CAMERA_COMMANDS,
...LOCATION_COMMANDS,
...ANDROID_NOTIFICATION_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
...ANDROID_DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,

View File

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

View File

@@ -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<typeof loadSessionEntry>;
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;