| 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 | 84 | cancelActivity(context) |
| 85 | 85 | context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() |
| 86 | 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 | 91 | .forEach { manager.cancel(it.tag, it.id) } |
| 89 | 92 | } |
| 90 | 93 | |
| ... | ... | @@ -157,13 +160,35 @@ object AgentNotifications { |
| 157 | 160 | // Grouped alerts list up to five 120-character thread titles. |
| 158 | 161 | val body = data["alert_body"].orEmpty().take(608) |
| 159 | 162 | val id = alertId.hashCode() |
| 163 | val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG | |
| 160 | 164 | val notification = base(context, ALERT_CHANNEL) |
| 161 | 165 | .setContentTitle(title).setContentText(body) |
| 162 | 166 | .setStyle(NotificationCompat.BigTextStyle().bigText(body)) |
| 163 | 167 | .setAutoCancel(true) |
| 168 | .setGroup(group) | |
| 164 | 169 | .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) |
| 165 | 170 | .build() |
| 166 | 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 | 60 | "activity_body" to "Test thread · Working", |
| 61 | 61 | "activity_path" to "/threads/environment/thread", |
| 62 | 62 | "alert_id" to alertId, |
| 63 | "alert_group" to "environment/thread", | |
| 63 | 64 | "alert_title" to "Test thread", |
| 64 | 65 | "alert_body" to "Done: Test project", |
| 65 | 66 | "alert_path" to "/threads/environment/thread", |
| ... | ... | @@ -76,6 +77,12 @@ class AgentNotificationsTest { |
| 76 | 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 | 86 | @Test |
| 80 | 87 | fun missingLauncherDoesNotDiscardTheAlert() { |
| 81 | 88 | shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) |
| ... | ... | @@ -142,6 +149,22 @@ class AgentNotificationsTest { |
| 142 | 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 | 168 | @Test |
| 146 | 169 | fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { |
| 147 | 170 | val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") |
| ... | ... | @@ -286,11 +309,11 @@ class AgentNotificationsTest { |
| 286 | 309 | context, |
| 287 | 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 | 313 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) |
| 291 | 314 | shadowOf(manager).setNotificationsEnabled(false) |
| 292 | 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 | 319 | @Test |
| ... | ... | @@ -503,7 +526,8 @@ class AgentNotificationsTest { |
| 503 | 526 | AgentNotifications.expire(context, expiresAt + 60_000) |
| 504 | 527 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) |
| 505 | 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 | 531 | assertTrue(alarms.scheduledAlarms.isEmpty()) |
| 508 | 532 | } |
| 509 | 533 |
infra/relay/src/agentActivity/ApnsClient.test.ts+1| ... | ... | @@ -187,6 +187,7 @@ describe("ApnsClient", () => { |
| 187 | 187 | body: "Input: Project", |
| 188 | 188 | }, |
| 189 | 189 | sound: "default", |
| 190 | "thread-id": "env/thread", | |
| 190 | 191 | }, |
| 191 | 192 | environmentId: "env", |
| 192 | 193 | threadId: "thread", |
infra/relay/src/agentActivity/ApnsClient.ts+9| ... | ... | @@ -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 | 177 | function makePushNotificationRequest(input: { |
| 172 | 178 | readonly token: string; |
| 173 | 179 | readonly notification: ApnsNotificationPayload; |
| ... | ... | @@ -182,6 +188,9 @@ function makePushNotificationRequest(input: { |
| 182 | 188 | body: input.notification.body, |
| 183 | 189 | }, |
| 184 | 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 | 195 | environmentId: input.notification.environmentId, |
| 187 | 196 | threadId: input.notification.threadId, |
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25| ... | ... | @@ -454,9 +454,34 @@ describe("Android delivery routing", () => { |
| 454 | 454 | ).toMatchObject({ |
| 455 | 455 | alert_title: `${"T".repeat(117)}...`, |
| 456 | 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 | 485 | it.effect( |
| 461 | 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 | 67 | const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); |
| 68 | 68 | return { |
| 69 | 69 | alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), |
| 70 | alert_group: JSON.stringify([state.environmentId, state.threadId]), | |
| 70 | 71 | alert_title: notification.title, |
| 71 | 72 | alert_body: notification.body, |
| 72 | 73 | alert_path: notification.deepLink, |
| ... | ... | @@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: { |
| 92 | 93 | const notification = notificationForActivity(first); |
| 93 | 94 | return { |
| 94 | 95 | alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), |
| 96 | alert_group: JSON.stringify([first.environmentId, first.threadId]), | |
| 95 | 97 | alert_title: notification.title, |
| 96 | 98 | alert_body: notification.body, |
| 97 | 99 | alert_path: notification.deepLink, |
| ... | ... | @@ -210,7 +212,7 @@ export const make = Effect.gen(function* () { |
| 210 | 212 | const previousAggregate = target.last_aggregate_json |
| 211 | 213 | ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) |
| 212 | 214 | : null; |
| 213 | let alert: ReturnType<typeof androidAlertForState> = null; | |
| 215 | let alert: ReturnType<typeof androidAlertForState | typeof androidAlertForAggregate> = null; | |
| 214 | 216 | // Deletion jobs can observe another thread's newly completed state. They |
| 215 | 217 | // update the card, but must leave that transition for its own alert job. |
| 216 | 218 | // Registration replay deliberately establishes a silent baseline. |
| ... | ... | @@ -291,7 +293,7 @@ export const make = Effect.gen(function* () { |
| 291 | 293 | // A registration replay must clear an orphan even when the relay has |
| 292 | 294 | // already forgotten its baseline. Finished cards are visible, but idle. |
| 293 | 295 | if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; |
| 294 | const data = { | |
| 296 | const data: Record<string, string> = { | |
| 295 | 297 | t3_kind: "agent_activity", |
| 296 | 298 | device_id: job.deviceId, |
| 297 | 299 | user_id: job.userId, |