authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:25-07:00
logbed69f6ea41dab13ec37afbaaa2c911a19865f7a
treeec43bbd8cc8915791649ae5c32ba9d72a75d0496
parent1e9c36023c898bfcab6dce862e105441b85ec22e
signature Signed by PGP key B5690EEEBB952194

feat(mobile): suppress only the on-screen thread's alert on Android (#12052)

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 {
9191 .forEach { manager.cancel(it.tag, it.id) }
9292 }
9393
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
94101 @Synchronized
95102 fun dismiss(context: Context) {
96103 context.getSharedPreferences(
......@@ -138,9 +145,13 @@ object AgentNotifications {
138145 val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n')
139146 ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList()
140147 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) {
144155 postAlert(context, scheme, data, alertId)
145156 }
146157 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() {
3030 appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) }
3131 }
3232
33 Function("setThreadOnScreen") { path: String? ->
34 AgentNotifications.setThreadOnScreen(path)
35 }
36
3337 Function("openLiveUpdateSettings") {
3438 val context = appContext.reactContext
3539 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 {
4949 )
5050 AgentNotifications.clear(context)
5151 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")
5254 }
5355
5456 private fun update(alertId: String, active: Boolean) = mapOf(
......@@ -93,6 +95,14 @@ class AgentNotificationsTest {
9395 )
9496 }
9597
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
96106 @Test
97107 fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() {
98108 lifecycle.currentState = Lifecycle.State.RESUMED
......@@ -187,7 +197,7 @@ class AgentNotificationsTest {
187197 }
188198
189199 @Test
190 fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() {
200 fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() {
191201 val grouped = update("group-attention", true) + mapOf(
192202 "alert_title" to "2 agents need attention",
193203 "alert_body" to "First thread, Second thread",
......@@ -195,10 +205,13 @@ class AgentNotificationsTest {
195205 )
196206 lifecycle.currentState = Lifecycle.State.RESUMED
197207 AgentNotifications.receive(context, grouped)
208 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" })
198209 lifecycle.currentState = Lifecycle.State.CREATED
199210 AgentNotifications.receive(context, grouped)
200211
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" })
202215 }
203216
204217 @Test
apps/mobile/src/features/agent-awareness/androidNotifications.ts+6
......@@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native";
55interface AndroidAgentNotifications {
66 configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
77 clear(): void;
8 setThreadOnScreen?(path: string | null): void;
89 openLiveUpdateSettings?(): boolean;
910 showShowcaseActivity?(scheme: string, data: Record<string, string>): void;
1011}
......@@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void {
4243 native?.clear?.();
4344}
4445
46/** Tells the FCM handler which thread route is on screen so its alerts stay quiet. */
47export function setAndroidThreadOnScreen(path: string | null): void {
48 native?.setThreadOnScreen?.(path);
49}
50
4551export function supportsAndroidLiveUpdateSettings(): boolean {
4652 return Platform.OS === "android" && Number(Platform.Version) >= 36;
4753}
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+9-2
......@@ -1,7 +1,8 @@
1import { useEffect, useRef } from "react";
1import { useEffect, useLayoutEffect, useRef } from "react";
22import * as Notifications from "expo-notifications";
33import { useLinkTo } from "@react-navigation/native";
44
5import { setAndroidThreadOnScreen } from "./androidNotifications";
56import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior";
67import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload";
78import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
......@@ -12,7 +13,13 @@ export function useAgentNotificationNavigation(pathname: string): void {
1213 // Read through a ref so the native handler registered once below sees the
1314 // current route without re-registering on every navigation.
1415 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]);
1623
1724 useEffect(() => {
1825 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
6868
6969Supported 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.
7070
71Android 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.
71With 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.
7272
7373With 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.
7474
infra/relay/src/agentActivity/FcmDeliveries.test.ts+25-22
......@@ -225,28 +225,31 @@ describe("Android delivery routing", () => {
225225 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],
226226 ["completed", "failed", "2 agents finished", "false"],
227227 ] 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 );
250253 }
251254
252255 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";
2222import * as LiveActivities from "./LiveActivities.ts";
2323import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts";
2424import * as FcmClient from "./FcmClient.ts";
25import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts";
25import { androidActivityData, fitFcmData } from "./fcmPayloads.ts";
2626import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts";
2727import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts";
2828import {
......@@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: {
109109 ),
110110 alert_title: alert.title,
111111 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: "/",
113115 };
114116}
115117