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 09f9e278f12f..5c7cd2e43560 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 @@ -91,6 +91,13 @@ object AgentNotifications { .forEach { manager.cancel(it.tag, it.id) } } + /** Records the thread route the app is showing, or null when none is open. */ + @Volatile private var threadOnScreen: String? = null + + fun setThreadOnScreen(path: String?) { + threadOnScreen = path + } + @Synchronized fun dismiss(context: Context) { context.getSharedPreferences( @@ -138,9 +145,13 @@ object AgentNotifications { val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() if (alertId != null && alertId !in seen) { - // Match iOS foreground presentation. Consume suppressed alerts as well, - // so a delivery retry cannot surface them after the app backgrounds. - if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + // Consume suppressed alerts so retries cannot resurface them later. + val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast( + Lifecycle.State.RESUMED + ) + val visibleThread = threadOnScreen + val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread + if (!onScreen) { postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index 0a2d2b30589f..cbef915cb7da 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -30,6 +30,10 @@ class T3AgentNotificationsModule : Module() { appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } } + Function("setThreadOnScreen") { path: String? -> + AgentNotifications.setThreadOnScreen(path) + } + Function("openLiveUpdateSettings") { val context = appContext.reactContext if (context == null || Build.VERSION.SDK_INT < 36) { 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 3852e3e2a0b2..7492bf743dcb 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 @@ -49,6 +49,8 @@ class AgentNotificationsTest { ) AgentNotifications.clear(context) AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + // The fixtures alert for this thread; a resumed app is showing it. + AgentNotifications.setThreadOnScreen("/threads/environment/thread") } private fun update(alertId: String, active: Boolean) = mapOf( @@ -93,6 +95,14 @@ class AgentNotificationsTest { ) } + @Test + fun foregroundAlertsForThreadsThatAreNotOnScreen() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.setThreadOnScreen("/threads/environment/other") + AgentNotifications.receive(context, update("elsewhere", false)) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + @Test fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -187,7 +197,7 @@ class AgentNotificationsTest { } @Test - fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { + fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() { val grouped = update("group-attention", true) + mapOf( "alert_title" to "2 agents need attention", "alert_body" to "First thread, Second thread", @@ -195,10 +205,13 @@ class AgentNotificationsTest { ) lifecycle.currentState = Lifecycle.State.RESUMED AgentNotifications.receive(context, grouped) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) lifecycle.currentState = Lifecycle.State.CREATED AgentNotifications.receive(context, grouped) - assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + assertEquals(2, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) } @Test diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index a2a0945faaf1..73f84434d89d 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native"; interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; + setThreadOnScreen?(path: string | null): void; openLiveUpdateSettings?(): boolean; showShowcaseActivity?(scheme: string, data: Record): void; } @@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void { native?.clear?.(); } +/** Tells the FCM handler which thread route is on screen so its alerts stay quiet. */ +export function setAndroidThreadOnScreen(path: string | null): void { + native?.setThreadOnScreen?.(path); +} + export function supportsAndroidLiveUpdateSettings(): boolean { return Platform.OS === "android" && Number(Platform.Version) >= 36; } diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index ff65371ba3b6..099fb5055990 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,7 +1,8 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import { useLinkTo } from "@react-navigation/native"; +import { setAndroidThreadOnScreen } from "./androidNotifications"; import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; @@ -12,7 +13,13 @@ export function useAgentNotificationNavigation(pathname: string): void { // Read through a ref so the native handler registered once below sees the // current route without re-registering on every navigation. const deepLinkOnScreen = useRef(null); - deepLinkOnScreen.current = threadDeepLinkOnScreen(pathname); + useLayoutEffect(() => { + const thread = threadDeepLinkOnScreen(pathname); + deepLinkOnScreen.current = thread; + // Android alerts are built natively from FCM data, so update the native + // route at commit time alongside the iOS handler's route reference. + setAndroidThreadOnScreen(thread); + }, [pathname]); useEffect(() => { Notifications.setNotificationHandler({ diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index 3ae100c39f5c..aa29ef3e1a7b 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -68,7 +68,7 @@ vp run push:android:smoke /path/service-account.json /path/device.json completed 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. -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. +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. 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. diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 91ad9ad301e3..46254e1e8b43 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -225,28 +225,31 @@ describe("Android delivery routing", () => { ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], ["completed", "failed", "2 agents finished", "false"], ] as const) { - it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { - const h = harness(); - h.current.otherStates = [secondState]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries; - yield* delivery.process(h.job); - h.current.state = { ...state, phase: firstPhase }; - h.current.otherStates = [{ ...secondState, phase: secondPhase }]; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); - yield* delivery.process({ ...h.job, state: h.current.state }); - const alerts = h.sent.filter((message) => message.alert); - expect(alerts).toHaveLength(1); - expect(alerts[0]?.data).toMatchObject({ - alert_title: title, - alert_body: "Fix notifications, Second thread", - alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, - active, - }); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + it.effect( + `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, + () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: "/", + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); } it.effect("filters disabled event types before counting a group", () => { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 3433055f5dca..8f830a8af7b4 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -22,7 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; import * as FcmClient from "./FcmClient.ts"; -import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; +import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; import { @@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: { ), alert_title: alert.title, alert_body: alert.body, - alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", + // A multi-thread alert targets the overview so one visible thread cannot + // suppress notifications for the other threads in the group. + alert_path: "/", }; }