| author | |
| committer | |
| log | bed69f6ea41dab13ec37afbaaa2c911a19865f7a |
| tree | ec43bbd8cc8915791649ae5c32ba9d72a75d0496 |
| parent | 1e9c36023c898bfcab6dce862e105441b85ec22e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>8 files changed, 78 insertions(+), 32 deletions(-)
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt+14-3| ... | ... | @@ -91,6 +91,13 @@ object AgentNotifications { |
| 91 | 91 | .forEach { manager.cancel(it.tag, it.id) } |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | /** Records the thread route the app is showing, or null when none is open. */ | |
| 95 | @Volatile private var threadOnScreen: String? = null | |
| 96 | ||
| 97 | fun setThreadOnScreen(path: String?) { | |
| 98 | threadOnScreen = path | |
| 99 | } | |
| 100 | ||
| 94 | 101 | @Synchronized |
| 95 | 102 | fun dismiss(context: Context) { |
| 96 | 103 | context.getSharedPreferences( |
| ... | ... | @@ -138,9 +145,13 @@ object AgentNotifications { |
| 138 | 145 | val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') |
| 139 | 146 | ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() |
| 140 | 147 | if (alertId != null && alertId !in seen) { |
| 141 | // Match iOS foreground presentation. Consume suppressed alerts as well, | |
| 142 | // so a delivery retry cannot surface them after the app backgrounds. | |
| 143 | if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { | |
| 148 | // Consume suppressed alerts so retries cannot resurface them later. | |
| 149 | val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast( | |
| 150 | Lifecycle.State.RESUMED | |
| 151 | ) | |
| 152 | val visibleThread = threadOnScreen | |
| 153 | val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread | |
| 154 | if (!onScreen) { | |
| 144 | 155 | postAlert(context, scheme, data, alertId) |
| 145 | 156 | } |
| 146 | 157 | prefs.edit().remove("seenAlerts").putString( |
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt+4| ... | ... | @@ -30,6 +30,10 @@ class T3AgentNotificationsModule : Module() { |
| 30 | 30 | appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | Function("setThreadOnScreen") { path: String? -> | |
| 34 | AgentNotifications.setThreadOnScreen(path) | |
| 35 | } | |
| 36 | ||
| 33 | 37 | Function("openLiveUpdateSettings") { |
| 34 | 38 | val context = appContext.reactContext |
| 35 | 39 | if (context == null || Build.VERSION.SDK_INT < 36) { |
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt+15-2| ... | ... | @@ -49,6 +49,8 @@ class AgentNotificationsTest { |
| 49 | 49 | ) |
| 50 | 50 | AgentNotifications.clear(context) |
| 51 | 51 | AgentNotifications.configure(context, "device", "user", "t3code-dev", true) |
| 52 | // The fixtures alert for this thread; a resumed app is showing it. | |
| 53 | AgentNotifications.setThreadOnScreen("/threads/environment/thread") | |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | private fun update(alertId: String, active: Boolean) = mapOf( |
| ... | ... | @@ -93,6 +95,14 @@ class AgentNotificationsTest { |
| 93 | 95 | ) |
| 94 | 96 | } |
| 95 | 97 | |
| 98 | @Test | |
| 99 | fun foregroundAlertsForThreadsThatAreNotOnScreen() { | |
| 100 | lifecycle.currentState = Lifecycle.State.RESUMED | |
| 101 | AgentNotifications.setThreadOnScreen("/threads/environment/other") | |
| 102 | AgentNotifications.receive(context, update("elsewhere", false)) | |
| 103 | assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) | |
| 104 | } | |
| 105 | ||
| 96 | 106 | @Test |
| 97 | 107 | fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { |
| 98 | 108 | lifecycle.currentState = Lifecycle.State.RESUMED |
| ... | ... | @@ -187,7 +197,7 @@ class AgentNotificationsTest { |
| 187 | 197 | } |
| 188 | 198 | |
| 189 | 199 | @Test |
| 190 | fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { | |
| 200 | fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() { | |
| 191 | 201 | val grouped = update("group-attention", true) + mapOf( |
| 192 | 202 | "alert_title" to "2 agents need attention", |
| 193 | 203 | "alert_body" to "First thread, Second thread", |
| ... | ... | @@ -195,10 +205,13 @@ class AgentNotificationsTest { |
| 195 | 205 | ) |
| 196 | 206 | lifecycle.currentState = Lifecycle.State.RESUMED |
| 197 | 207 | AgentNotifications.receive(context, grouped) |
| 208 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) | |
| 198 | 209 | lifecycle.currentState = Lifecycle.State.CREATED |
| 199 | 210 | AgentNotifications.receive(context, grouped) |
| 200 | 211 | |
| 201 | assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) | |
| 212 | assertEquals(2, manager.activeNotifications.size) | |
| 213 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) | |
| 214 | assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) | |
| 202 | 215 | } |
| 203 | 216 | |
| 204 | 217 | @Test |
apps/mobile/src/features/agent-awareness/androidNotifications.ts+6| ... | ... | @@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native"; |
| 5 | 5 | interface AndroidAgentNotifications { |
| 6 | 6 | configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; |
| 7 | 7 | clear(): void; |
| 8 | setThreadOnScreen?(path: string | null): void; | |
| 8 | 9 | openLiveUpdateSettings?(): boolean; |
| 9 | 10 | showShowcaseActivity?(scheme: string, data: Record<string, string>): void; |
| 10 | 11 | } |
| ... | ... | @@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void { |
| 42 | 43 | native?.clear?.(); |
| 43 | 44 | } |
| 44 | 45 | |
| 46 | /** Tells the FCM handler which thread route is on screen so its alerts stay quiet. */ | |
| 47 | export function setAndroidThreadOnScreen(path: string | null): void { | |
| 48 | native?.setThreadOnScreen?.(path); | |
| 49 | } | |
| 50 | ||
| 45 | 51 | export function supportsAndroidLiveUpdateSettings(): boolean { |
| 46 | 52 | return Platform.OS === "android" && Number(Platform.Version) >= 36; |
| 47 | 53 | } |
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+9-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { useEffect, useRef } from "react"; | |
| 1 | import { useEffect, useLayoutEffect, useRef } from "react"; | |
| 2 | 2 | import * as Notifications from "expo-notifications"; |
| 3 | 3 | import { useLinkTo } from "@react-navigation/native"; |
| 4 | 4 | |
| 5 | import { setAndroidThreadOnScreen } from "./androidNotifications"; | |
| 5 | 6 | import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; |
| 6 | 7 | import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; |
| 7 | 8 | import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; |
| ... | ... | @@ -12,7 +13,13 @@ export function useAgentNotificationNavigation(pathname: string): void { |
| 12 | 13 | // Read through a ref so the native handler registered once below sees the |
| 13 | 14 | // current route without re-registering on every navigation. |
| 14 | 15 | const deepLinkOnScreen = useRef<string | null>(null); |
| 15 | deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); | |
| 16 | useLayoutEffect(() => { | |
| 17 | const thread = threadDeepLinkOnScreen(pathname); | |
| 18 | deepLinkOnScreen.current = thread; | |
| 19 | // Android alerts are built natively from FCM data, so update the native | |
| 20 | // route at commit time alongside the iOS handler's route reference. | |
| 21 | setAndroidThreadOnScreen(thread); | |
| 22 | }, [pathname]); | |
| 16 | 23 | |
| 17 | 24 | useEffect(() => { |
| 18 | 25 | Notifications.setNotificationHandler({ |
docs/operations/android-notifications.md+1-1| ... | ... | @@ -68,7 +68,7 @@ vp run push:android:smoke /path/service-account.json /path/device.json completed |
| 68 | 68 | |
| 69 | 69 | Supported states are `running`, `approval`, `input`, `completed`, `failed`, and `end`. Firebase acceptance is not proof that a device displayed the message. Check the actual notification, background the app, and test a notification tap. Also test dismissal, disabling ongoing activity, sign-out, token rotation, and delivery after the app process has exited. Android Settings **Force stop** intentionally prevents delivery until the app is opened again. |
| 70 | 70 | |
| 71 | Android suppresses ordinary alerts while the app is foregrounded, matching iOS notification presentation. Activity cards still update in the foreground and retain finished results silently. Check that completion stays quiet with the app open, that a later completion alerts after backgrounding, and that retrying a foreground-suppressed alert does not show it later. This uses the app lifecycle on the receiving phone, not thread visibility on other clients. | |
| 71 | With the app in the foreground, Android suppresses an alert only for the thread currently on screen, matching iOS notification presentation; alerts for other threads still show. Activity cards still update in the foreground and retain finished results silently. Check that completion stays quiet while its thread is open, alerts while another screen is open, alerts after backgrounding, and that retrying a suppressed alert does not show it later. This uses the app lifecycle and route on the receiving phone, not thread visibility on other clients. | |
| 72 | 72 | |
| 73 | 73 | With ongoing activity enabled, verify two threads entering approval/input together produce one `2 agents need attention` alert, and two observed active threads completing/failing together produce one `2 agents finished` alert. The body lists their titles. The relay shares iOS transition selection and retains its delivered baseline when work finishes; publishing the same states again must not produce another alert. Grouped alerts open the aggregate’s priority thread; individual alerts retain their thread link. |
| 74 | 74 |
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25-22| ... | ... | @@ -225,28 +225,31 @@ describe("Android delivery routing", () => { |
| 225 | 225 | ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], |
| 226 | 226 | ["completed", "failed", "2 agents finished", "false"], |
| 227 | 227 | ] as const) { |
| 228 | it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { | |
| 229 | const h = harness(); | |
| 230 | h.current.otherStates = [secondState]; | |
| 231 | return Effect.gen(function* () { | |
| 232 | const delivery = yield* FcmDeliveries; | |
| 233 | yield* delivery.process(h.job); | |
| 234 | h.current.state = { ...state, phase: firstPhase }; | |
| 235 | h.current.otherStates = [{ ...secondState, phase: secondPhase }]; | |
| 236 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 237 | yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); | |
| 238 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 239 | const alerts = h.sent.filter((message) => message.alert); | |
| 240 | expect(alerts).toHaveLength(1); | |
| 241 | expect(alerts[0]?.data).toMatchObject({ | |
| 242 | alert_title: title, | |
| 243 | alert_body: "Fix notifications, Second thread", | |
| 244 | alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, | |
| 245 | active, | |
| 246 | }); | |
| 247 | expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); | |
| 248 | }).pipe(Effect.provide(h.layer)); | |
| 249 | }); | |
| 228 | it.effect( | |
| 229 | `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, | |
| 230 | () => { | |
| 231 | const h = harness(); | |
| 232 | h.current.otherStates = [secondState]; | |
| 233 | return Effect.gen(function* () { | |
| 234 | const delivery = yield* FcmDeliveries; | |
| 235 | yield* delivery.process(h.job); | |
| 236 | h.current.state = { ...state, phase: firstPhase }; | |
| 237 | h.current.otherStates = [{ ...secondState, phase: secondPhase }]; | |
| 238 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 239 | yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); | |
| 240 | yield* delivery.process({ ...h.job, state: h.current.state }); | |
| 241 | const alerts = h.sent.filter((message) => message.alert); | |
| 242 | expect(alerts).toHaveLength(1); | |
| 243 | expect(alerts[0]?.data).toMatchObject({ | |
| 244 | alert_title: title, | |
| 245 | alert_body: "Fix notifications, Second thread", | |
| 246 | alert_path: "/", | |
| 247 | active, | |
| 248 | }); | |
| 249 | expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); | |
| 250 | }).pipe(Effect.provide(h.layer)); | |
| 251 | }, | |
| 252 | ); | |
| 250 | 253 | } |
| 251 | 254 | |
| 252 | 255 | it.effect("filters disabled event types before counting a group", () => { |
infra/relay/src/agentActivity/FcmDeliveries.ts+4-2| ... | ... | @@ -22,7 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; |
| 22 | 22 | import * as LiveActivities from "./LiveActivities.ts"; |
| 23 | 23 | import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; |
| 24 | 24 | import * as FcmClient from "./FcmClient.ts"; |
| 25 | import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; | |
| 25 | import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; | |
| 26 | 26 | import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; |
| 27 | 27 | import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; |
| 28 | 28 | import { |
| ... | ... | @@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: { |
| 109 | 109 | ), |
| 110 | 110 | alert_title: alert.title, |
| 111 | 111 | alert_body: alert.body, |
| 112 | alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", | |
| 112 | // A multi-thread alert targets the overview so one visible thread cannot | |
| 113 | // suppress notifications for the other threads in the group. | |
| 114 | alert_path: "/", | |
| 113 | 115 | }; |
| 114 | 116 | } |
| 115 | 117 |