| author | |
| committer | |
| log | 438af295fefdd04cc38629f902d7faa07cf39712 |
| tree | d9afa8a2b847a2596f021f4f4993df8d6c47ee8e |
| parent | 09388bf2da51b958fe5c11fb5bade155fe575e58 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 92 insertions(+), 6 deletions(-)
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt+26-1| ... | @@ -84,7 +84,10 @@ object AgentNotifications { | ... | @@ -84,7 +84,10 @@ object AgentNotifications { |
| 84 | cancelActivity(context) | 84 | cancelActivity(context) |
| 85 | context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() | 85 | context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() |
| 86 | val manager = manager(context) | 86 | val manager = manager(context) |
| 87 | manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG } | 87 | manager.activeNotifications.filter { |
| 88 | it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG || | ||
| 89 | it.tag?.startsWith("$ALERT_TAG-summary:") == true | ||
| 90 | } | ||
| 88 | .forEach { manager.cancel(it.tag, it.id) } | 91 | .forEach { manager.cancel(it.tag, it.id) } |
| 89 | } | 92 | } |
| 90 | 93 | ||
| ... | @@ -157,13 +160,35 @@ object AgentNotifications { | ... | @@ -157,13 +160,35 @@ object AgentNotifications { |
| 157 | // Grouped alerts list up to five 120-character thread titles. | 160 | // Grouped alerts list up to five 120-character thread titles. |
| 158 | val body = data["alert_body"].orEmpty().take(608) | 161 | val body = data["alert_body"].orEmpty().take(608) |
| 159 | val id = alertId.hashCode() | 162 | val id = alertId.hashCode() |
| 163 | val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG | ||
| 160 | val notification = base(context, ALERT_CHANNEL) | 164 | val notification = base(context, ALERT_CHANNEL) |
| 161 | .setContentTitle(title).setContentText(body) | 165 | .setContentTitle(title).setContentText(body) |
| 162 | .setStyle(NotificationCompat.BigTextStyle().bigText(body)) | 166 | .setStyle(NotificationCompat.BigTextStyle().bigText(body)) |
| 163 | .setAutoCancel(true) | 167 | .setAutoCancel(true) |
| 168 | .setGroup(group) | ||
| 164 | .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) | 169 | .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) |
| 165 | .build() | 170 | .build() |
| 166 | manager(context).notify(ALERT_TAG, id, notification) | 171 | manager(context).notify(ALERT_TAG, id, notification) |
| 172 | val children = manager(context).activeNotifications.filter { | ||
| 173 | it.tag == ALERT_TAG && it.notification.group == group | ||
| 174 | } | ||
| 175 | if (children.size > 1) { | ||
| 176 | val style = NotificationCompat.InboxStyle() | ||
| 177 | children.forEach { | ||
| 178 | style.addLine(it.notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)) | ||
| 179 | } | ||
| 180 | val summary = base(context, ALERT_CHANNEL) | ||
| 181 | .setContentTitle(title) | ||
| 182 | .setContentText(body) | ||
| 183 | .setStyle(style) | ||
| 184 | .setGroup(group) | ||
| 185 | .setGroupSummary(true) | ||
| 186 | .setSilent(true) | ||
| 187 | .setAutoCancel(true) | ||
| 188 | .setContentIntent(contentIntent(context, scheme, data["alert_path"], 0)) | ||
| 189 | .build() | ||
| 190 | manager(context).notify("$ALERT_TAG-summary:$group", 0, summary) | ||
| 191 | } | ||
| 167 | } | 192 | } |
| 168 | 193 | ||
| 169 | /** | 194 | /** |
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt+27-3| ... | @@ -60,6 +60,7 @@ class AgentNotificationsTest { | ... | @@ -60,6 +60,7 @@ class AgentNotificationsTest { |
| 60 | "activity_body" to "Test thread · Working", | 60 | "activity_body" to "Test thread · Working", |
| 61 | "activity_path" to "/threads/environment/thread", | 61 | "activity_path" to "/threads/environment/thread", |
| 62 | "alert_id" to alertId, | 62 | "alert_id" to alertId, |
| 63 | "alert_group" to "environment/thread", | ||
| 63 | "alert_title" to "Test thread", | 64 | "alert_title" to "Test thread", |
| 64 | "alert_body" to "Done: Test project", | 65 | "alert_body" to "Done: Test project", |
| 65 | "alert_path" to "/threads/environment/thread", | 66 | "alert_path" to "/threads/environment/thread", |
| ... | @@ -76,6 +77,12 @@ class AgentNotificationsTest { | ... | @@ -76,6 +77,12 @@ class AgentNotificationsTest { |
| 76 | assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) | 77 | assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) |
| 77 | } | 78 | } |
| 78 | 79 | ||
| 80 | @Test | ||
| 81 | fun alertsStackByThreadGroup() { | ||
| 82 | AgentNotifications.receive(context, update("grouped", false)) | ||
| 83 | assertEquals("environment/thread", manager.activeNotifications.single().notification.group) | ||
| 84 | } | ||
| 85 | |||
| 79 | @Test | 86 | @Test |
| 80 | fun missingLauncherDoesNotDiscardTheAlert() { | 87 | fun missingLauncherDoesNotDiscardTheAlert() { |
| 81 | shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) | 88 | shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) |
| ... | @@ -142,6 +149,22 @@ class AgentNotificationsTest { | ... | @@ -142,6 +149,22 @@ class AgentNotificationsTest { |
| 142 | assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) | 149 | assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) |
| 143 | } | 150 | } |
| 144 | 151 | ||
| 152 | @Test | ||
| 153 | fun repeatedThreadAlertsHaveOneSilentSummaryAndClearTogether() { | ||
| 154 | AgentNotifications.receive(context, update("first", false) + ("alert_group" to "thread-group")) | ||
| 155 | AgentNotifications.receive(context, update("second", false) + ("alert_group" to "thread-group")) | ||
| 156 | |||
| 157 | val summary = manager.activeNotifications.single { | ||
| 158 | it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 | ||
| 159 | } | ||
| 160 | assertEquals("thread-group", summary.notification.group) | ||
| 161 | assertEquals(3, manager.activeNotifications.size) | ||
| 162 | assertEquals(null, summary.notification.sound) | ||
| 163 | |||
| 164 | AgentNotifications.clear(context) | ||
| 165 | assertTrue(manager.activeNotifications.isEmpty()) | ||
| 166 | } | ||
| 167 | |||
| 145 | @Test | 168 | @Test |
| 146 | fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { | 169 | fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { |
| 147 | val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") | 170 | val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") |
| ... | @@ -286,11 +309,11 @@ class AgentNotificationsTest { | ... | @@ -286,11 +309,11 @@ class AgentNotificationsTest { |
| 286 | context, | 309 | context, |
| 287 | update("older-alert", false) + ("updated_at" to (now - 1000).toString()) | 310 | update("older-alert", false) + ("updated_at" to (now - 1000).toString()) |
| 288 | ) | 311 | ) |
| 289 | assertEquals(3, manager.activeNotifications.size) | 312 | assertEquals(4, manager.activeNotifications.size) |
| 290 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) | 313 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) |
| 291 | shadowOf(manager).setNotificationsEnabled(false) | 314 | shadowOf(manager).setNotificationsEnabled(false) |
| 292 | AgentNotifications.receive(context, update("revoked-permission", true)) | 315 | AgentNotifications.receive(context, update("revoked-permission", true)) |
| 293 | assertEquals(3, manager.activeNotifications.size) | 316 | assertEquals(4, manager.activeNotifications.size) |
| 294 | } | 317 | } |
| 295 | 318 | ||
| 296 | @Test | 319 | @Test |
| ... | @@ -503,7 +526,8 @@ class AgentNotificationsTest { | ... | @@ -503,7 +526,8 @@ class AgentNotificationsTest { |
| 503 | AgentNotifications.expire(context, expiresAt + 60_000) | 526 | AgentNotifications.expire(context, expiresAt + 60_000) |
| 504 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) | 527 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) |
| 505 | AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) | 528 | AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) |
| 506 | assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" }) | 529 | assertEquals(2, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) |
| 530 | assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" }) | ||
| 507 | assertTrue(alarms.scheduledAlarms.isEmpty()) | 531 | assertTrue(alarms.scheduledAlarms.isEmpty()) |
| 508 | } | 532 | } |
| 509 | 533 |
infra/relay/src/agentActivity/ApnsClient.test.ts+1| ... | @@ -187,6 +187,7 @@ describe("ApnsClient", () => { | ... | @@ -187,6 +187,7 @@ describe("ApnsClient", () => { |
| 187 | body: "Input: Project", | 187 | body: "Input: Project", |
| 188 | }, | 188 | }, |
| 189 | sound: "default", | 189 | sound: "default", |
| 190 | "thread-id": "env/thread", | ||
| 190 | }, | 191 | }, |
| 191 | environmentId: "env", | 192 | environmentId: "env", |
| 192 | threadId: "thread", | 193 | threadId: "thread", |
infra/relay/src/agentActivity/ApnsClient.ts+9| ... | @@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA | ... | @@ -168,6 +168,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA |
| 168 | }; | 168 | }; |
| 169 | } | 169 | } |
| 170 | 170 | ||
| 171 | function notificationThreadId(notification: ApnsNotificationPayload): string { | ||
| 172 | return notification.threadId.length > 0 | ||
| 173 | ? `${notification.environmentId}/${notification.threadId}` | ||
| 174 | : "t3-agent-alerts"; | ||
| 175 | } | ||
| 176 | |||
| 171 | function makePushNotificationRequest(input: { | 177 | function makePushNotificationRequest(input: { |
| 172 | readonly token: string; | 178 | readonly token: string; |
| 173 | readonly notification: ApnsNotificationPayload; | 179 | readonly notification: ApnsNotificationPayload; |
| ... | @@ -182,6 +188,9 @@ function makePushNotificationRequest(input: { | ... | @@ -182,6 +188,9 @@ function makePushNotificationRequest(input: { |
| 182 | body: input.notification.body, | 188 | body: input.notification.body, |
| 183 | }, | 189 | }, |
| 184 | sound: "default", | 190 | sound: "default", |
| 191 | // Notification Center stacks alerts by thread so a chatty thread does | ||
| 192 | // not bury the others; a grouped alert for several threads stays alone. | ||
| 193 | "thread-id": notificationThreadId(input.notification), | ||
| 185 | }, | 194 | }, |
| 186 | environmentId: input.notification.environmentId, | 195 | environmentId: input.notification.environmentId, |
| 187 | threadId: input.notification.threadId, | 196 | threadId: input.notification.threadId, |
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25| ... | @@ -454,9 +454,34 @@ describe("Android delivery routing", () => { | ... | @@ -454,9 +454,34 @@ describe("Android delivery routing", () => { |
| 454 | ).toMatchObject({ | 454 | ).toMatchObject({ |
| 455 | alert_title: `${"T".repeat(117)}...`, | 455 | alert_title: `${"T".repeat(117)}...`, |
| 456 | alert_body: `Done: ${"P".repeat(111)}...`, | 456 | alert_body: `Done: ${"P".repeat(111)}...`, |
| 457 | alert_group: JSON.stringify([state.environmentId, state.threadId]), | ||
| 457 | }); | 458 | }); |
| 458 | }); | 459 | }); |
| 459 | 460 | ||
| 461 | it("keeps notification groups distinct when identifiers contain slashes", () => { | ||
| 462 | const left = androidAlertForState( | ||
| 463 | { | ||
| 464 | ...state, | ||
| 465 | environmentId: EnvironmentId.make("a/b"), | ||
| 466 | threadId: ThreadId.make("c"), | ||
| 467 | phase: "completed", | ||
| 468 | }, | ||
| 469 | preferences, | ||
| 470 | 0, | ||
| 471 | ); | ||
| 472 | const right = androidAlertForState( | ||
| 473 | { | ||
| 474 | ...state, | ||
| 475 | environmentId: EnvironmentId.make("a"), | ||
| 476 | threadId: ThreadId.make("b/c"), | ||
| 477 | phase: "completed", | ||
| 478 | }, | ||
| 479 | preferences, | ||
| 480 | 0, | ||
| 481 | ); | ||
| 482 | expect(left?.alert_group).not.toBe(right?.alert_group); | ||
| 483 | }); | ||
| 484 | |||
| 460 | it.effect( | 485 | it.effect( |
| 461 | "queues Android devices and sends the latest aggregate instead of a stale running state", | 486 | "queues Android devices and sends the latest aggregate instead of a stale running state", |
| 462 | () => { | 487 | () => { |
infra/relay/src/agentActivity/FcmDeliveries.ts+4-2| ... | @@ -67,6 +67,7 @@ export function androidAlertForState( | ... | @@ -67,6 +67,7 @@ export function androidAlertForState( |
| 67 | const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); | 67 | const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); |
| 68 | return { | 68 | return { |
| 69 | alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), | 69 | alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), |
| 70 | alert_group: JSON.stringify([state.environmentId, state.threadId]), | ||
| 70 | alert_title: notification.title, | 71 | alert_title: notification.title, |
| 71 | alert_body: notification.body, | 72 | alert_body: notification.body, |
| 72 | alert_path: notification.deepLink, | 73 | alert_path: notification.deepLink, |
| ... | @@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: { | ... | @@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: { |
| 92 | const notification = notificationForActivity(first); | 93 | const notification = notificationForActivity(first); |
| 93 | return { | 94 | return { |
| 94 | alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), | 95 | alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), |
| 96 | alert_group: JSON.stringify([first.environmentId, first.threadId]), | ||
| 95 | alert_title: notification.title, | 97 | alert_title: notification.title, |
| 96 | alert_body: notification.body, | 98 | alert_body: notification.body, |
| 97 | alert_path: notification.deepLink, | 99 | alert_path: notification.deepLink, |
| ... | @@ -210,7 +212,7 @@ export const make = Effect.gen(function* () { | ... | @@ -210,7 +212,7 @@ export const make = Effect.gen(function* () { |
| 210 | const previousAggregate = target.last_aggregate_json | 212 | const previousAggregate = target.last_aggregate_json |
| 211 | ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) | 213 | ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) |
| 212 | : null; | 214 | : null; |
| 213 | let alert: ReturnType<typeof androidAlertForState> = null; | 215 | let alert: ReturnType<typeof androidAlertForState | typeof androidAlertForAggregate> = null; |
| 214 | // Deletion jobs can observe another thread's newly completed state. They | 216 | // Deletion jobs can observe another thread's newly completed state. They |
| 215 | // update the card, but must leave that transition for its own alert job. | 217 | // update the card, but must leave that transition for its own alert job. |
| 216 | // Registration replay deliberately establishes a silent baseline. | 218 | // Registration replay deliberately establishes a silent baseline. |
| ... | @@ -291,7 +293,7 @@ export const make = Effect.gen(function* () { | ... | @@ -291,7 +293,7 @@ export const make = Effect.gen(function* () { |
| 291 | // A registration replay must clear an orphan even when the relay has | 293 | // A registration replay must clear an orphan even when the relay has |
| 292 | // already forgotten its baseline. Finished cards are visible, but idle. | 294 | // already forgotten its baseline. Finished cards are visible, but idle. |
| 293 | if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; | 295 | if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; |
| 294 | const data = { | 296 | const data: Record<string, string> = { |
| 295 | t3_kind: "agent_activity", | 297 | t3_kind: "agent_activity", |
| 296 | device_id: job.deviceId, | 298 | device_id: job.deviceId, |
| 297 | user_id: job.userId, | 299 | user_id: job.userId, |