diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 00af98ac4b283c5c052b2f54f92ff84c03e7cefc..09f9e278f12f710acff838bca2b8d5c528000527 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -84,7 +84,10 @@ object AgentNotifications { cancelActivity(context) context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() val manager = manager(context) - manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG } + manager.activeNotifications.filter { + it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG || + it.tag?.startsWith("$ALERT_TAG-summary:") == true + } .forEach { manager.cancel(it.tag, it.id) } } @@ -157,13 +160,35 @@ object AgentNotifications { // Grouped alerts list up to five 120-character thread titles. val body = data["alert_body"].orEmpty().take(608) val id = alertId.hashCode() + val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG val notification = base(context, ALERT_CHANNEL) .setContentTitle(title).setContentText(body) .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setAutoCancel(true) + .setGroup(group) .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) .build() manager(context).notify(ALERT_TAG, id, notification) + val children = manager(context).activeNotifications.filter { + it.tag == ALERT_TAG && it.notification.group == group + } + if (children.size > 1) { + val style = NotificationCompat.InboxStyle() + children.forEach { + style.addLine(it.notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)) + } + val summary = base(context, ALERT_CHANNEL) + .setContentTitle(title) + .setContentText(body) + .setStyle(style) + .setGroup(group) + .setGroupSummary(true) + .setSilent(true) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], 0)) + .build() + manager(context).notify("$ALERT_TAG-summary:$group", 0, summary) + } } /** diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 3e9b3b24c1938b115abe0317135226589dc7e50b..3852e3e2a0b238ac944d6072a0d164f12c98f3c2 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -60,6 +60,7 @@ class AgentNotificationsTest { "activity_body" to "Test thread ยท Working", "activity_path" to "/threads/environment/thread", "alert_id" to alertId, + "alert_group" to "environment/thread", "alert_title" to "Test thread", "alert_body" to "Done: Test project", "alert_path" to "/threads/environment/thread", @@ -76,6 +77,12 @@ class AgentNotificationsTest { assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) } + @Test + fun alertsStackByThreadGroup() { + AgentNotifications.receive(context, update("grouped", false)) + assertEquals("environment/thread", manager.activeNotifications.single().notification.group) + } + @Test fun missingLauncherDoesNotDiscardTheAlert() { shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) @@ -142,6 +149,22 @@ class AgentNotificationsTest { assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) } + @Test + fun repeatedThreadAlertsHaveOneSilentSummaryAndClearTogether() { + AgentNotifications.receive(context, update("first", false) + ("alert_group" to "thread-group")) + AgentNotifications.receive(context, update("second", false) + ("alert_group" to "thread-group")) + + val summary = manager.activeNotifications.single { + it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 + } + assertEquals("thread-group", summary.notification.group) + assertEquals(3, manager.activeNotifications.size) + assertEquals(null, summary.notification.sound) + + AgentNotifications.clear(context) + assertTrue(manager.activeNotifications.isEmpty()) + } + @Test fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") @@ -286,11 +309,11 @@ class AgentNotificationsTest { context, update("older-alert", false) + ("updated_at" to (now - 1000).toString()) ) - assertEquals(3, manager.activeNotifications.size) + assertEquals(4, manager.activeNotifications.size) assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) shadowOf(manager).setNotificationsEnabled(false) AgentNotifications.receive(context, update("revoked-permission", true)) - assertEquals(3, manager.activeNotifications.size) + assertEquals(4, manager.activeNotifications.size) } @Test @@ -503,7 +526,8 @@ class AgentNotificationsTest { AgentNotifications.expire(context, expiresAt + 60_000) assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) - assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" }) + assertEquals(2, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) + assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" }) assertTrue(alarms.scheduledAlarms.isEmpty()) } diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 03010ce48ce53a05b1c0e78f65b03678f57ca922..6c54600e0543b71309d33efe7e828cc033d716dd 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -187,6 +187,7 @@ describe("ApnsClient", () => { body: "Input: Project", }, sound: "default", + "thread-id": "env/thread", }, environmentId: "env", threadId: "thread", diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 93c47af02e36081f872d58a3969bdaa29c49b195..ec841be29b420b8dad4f87a977253c36e3ee024e 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA }; } +function notificationThreadId(notification: ApnsNotificationPayload): string { + return notification.threadId.length > 0 + ? `${notification.environmentId}/${notification.threadId}` + : "t3-agent-alerts"; +} + function makePushNotificationRequest(input: { readonly token: string; readonly notification: ApnsNotificationPayload; @@ -182,6 +188,9 @@ function makePushNotificationRequest(input: { body: input.notification.body, }, sound: "default", + // Notification Center stacks alerts by thread so a chatty thread does + // not bury the others; a grouped alert for several threads stays alone. + "thread-id": notificationThreadId(input.notification), }, environmentId: input.notification.environmentId, threadId: input.notification.threadId, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 8f061fca16ff91a191fa6d888208ab24ad6ddca6..91ad9ad301e36b51200ce9fac213f788e7234464 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -454,9 +454,34 @@ describe("Android delivery routing", () => { ).toMatchObject({ alert_title: `${"T".repeat(117)}...`, alert_body: `Done: ${"P".repeat(111)}...`, + alert_group: JSON.stringify([state.environmentId, state.threadId]), }); }); + it("keeps notification groups distinct when identifiers contain slashes", () => { + const left = androidAlertForState( + { + ...state, + environmentId: EnvironmentId.make("a/b"), + threadId: ThreadId.make("c"), + phase: "completed", + }, + preferences, + 0, + ); + const right = androidAlertForState( + { + ...state, + environmentId: EnvironmentId.make("a"), + threadId: ThreadId.make("b/c"), + phase: "completed", + }, + preferences, + 0, + ); + expect(left?.alert_group).not.toBe(right?.alert_group); + }); + it.effect( "queues Android devices and sends the latest aggregate instead of a stale running state", () => { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 8f998d9d95743eaed2d827b10c82a39d75aa3fca..3433055f5dcadb705d2fb937e2a4aec21c6eb613 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -67,6 +67,7 @@ export function androidAlertForState( const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); return { alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), + alert_group: JSON.stringify([state.environmentId, state.threadId]), alert_title: notification.title, alert_body: notification.body, alert_path: notification.deepLink, @@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: { const notification = notificationForActivity(first); return { alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), + alert_group: JSON.stringify([first.environmentId, first.threadId]), alert_title: notification.title, alert_body: notification.body, alert_path: notification.deepLink, @@ -210,7 +212,7 @@ export const make = Effect.gen(function* () { const previousAggregate = target.last_aggregate_json ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) : null; - let alert: ReturnType = null; + let alert: ReturnType = null; // Deletion jobs can observe another thread's newly completed state. They // update the card, but must leave that transition for its own alert job. // Registration replay deliberately establishes a silent baseline. @@ -291,7 +293,7 @@ export const make = Effect.gen(function* () { // A registration replay must clear an orphan even when the relay has // already forgotten its baseline. Finished cards are visible, but idle. if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; - const data = { + const data: Record = { t3_kind: "agent_activity", device_id: job.deviceId, user_id: job.userId,