From bed69f6ea41dab13ec37afbaaa2c911a19865f7a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 1 Oct 2026 10:51:25 -0700 Subject: [PATCH] feat(mobile): suppress only the on-screen thread's alert on Android (#12052) Co-authored-by: Claude Fable 5 --- .../AgentNotifications.kt | 17 +++++-- .../T3AgentNotificationsModule.kt | 4 ++ .../AgentNotificationsTest.kt | 17 ++++++- .../agent-awareness/androidNotifications.ts | 6 +++ .../agent-awareness/notificationNavigation.ts | 11 ++++- docs/operations/android-notifications.md | 2 +- .../src/agentActivity/FcmDeliveries.test.ts | 47 ++++++++++--------- .../relay/src/agentActivity/FcmDeliveries.ts | 6 ++- 8 files changed, 78 insertions(+), 32 deletions(-) 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 09f9e278f12f710acff838bca2b8d5c528000527..5c7cd2e43560c3d99bb941185e40c9981b1ccb94 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 0a2d2b30589fa5779389aeac2cbe0b004891da0a..cbef915cb7dad4a65afc326541718fe1c9a458ae 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 3852e3e2a0b238ac944d6072a0d164f12c98f3c2..7492bf743dcbfddbe7271a556ce3a3b4850cd6e0 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 a2a0945faaf19a05c1c2ef0445fe3d0699fad396..73f84434d89d090093fdd5d3fc644cc87dc6a7c7 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 ff65371ba3b6c0538aecdafb4b937224c94418e6..099fb505599020df24075a74826cd310a7c26f76 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 3ae100c39f5cdf19eb7f2f8d0de614687ee22f91..aa29ef3e1a7b4ab1f1dca7c267aa1b92754a2e01 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 91ad9ad301e36b51200ce9fac213f788e7234464..46254e1e8b43be4c34d9f643296347c46014cc35 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 3433055f5dcadb705d2fb937e2a4aec21c6eb613..8f830a8af7b4641020a0397c7672d870735d35c8 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: "/", }; } -- 2.54.0