feat(gateway): enable Android notify + notification events
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user