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 {...@@ -91,6 +91,13 @@ object AgentNotifications {
91 .forEach { manager.cancel(it.tag, it.id) }91 .forEach { manager.cancel(it.tag, it.id) }
92 }92 }
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
94 @Synchronized101 @Synchronized
95 fun dismiss(context: Context) {102 fun dismiss(context: Context) {
96 context.getSharedPreferences(103 context.getSharedPreferences(
...@@ -138,9 +145,13 @@ object AgentNotifications {...@@ -138,9 +145,13 @@ object AgentNotifications {
138 val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n')145 val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n')
139 ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList()146 ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList()
140 if (alertId != null && alertId !in seen) {147 if (alertId != null && alertId !in seen) {
141 // Match iOS foreground presentation. Consume suppressed alerts as well,148 // Consume suppressed alerts so retries cannot resurface them later.
142 // so a delivery retry cannot surface them after the app backgrounds.149 val resumed = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(
143 if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {150 Lifecycle.State.RESUMED
151 )
152 val visibleThread = threadOnScreen
153 val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread
154 if (!onScreen) {
144 postAlert(context, scheme, data, alertId)155 postAlert(context, scheme, data, alertId)
145 }156 }
146 prefs.edit().remove("seenAlerts").putString(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,6 +30,10 @@ class T3AgentNotificationsModule : Module() {
30 appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) }30 appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) }
31 }31 }
3232
33 Function("setThreadOnScreen") { path: String? ->
34 AgentNotifications.setThreadOnScreen(path)
35 }
36
33 Function("openLiveUpdateSettings") {37 Function("openLiveUpdateSettings") {
34 val context = appContext.reactContext38 val context = appContext.reactContext
35 if (context == null || Build.VERSION.SDK_INT < 36) {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,6 +49,8 @@ class AgentNotificationsTest {
49 )49 )
50 AgentNotifications.clear(context)50 AgentNotifications.clear(context)
51 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)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 }
5355
54 private fun update(alertId: String, active: Boolean) = mapOf(56 private fun update(alertId: String, active: Boolean) = mapOf(
...@@ -93,6 +95,14 @@ class AgentNotificationsTest {...@@ -93,6 +95,14 @@ class AgentNotificationsTest {
93 )95 )
94 }96 }
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
96 @Test106 @Test
97 fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() {107 fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() {
98 lifecycle.currentState = Lifecycle.State.RESUMED108 lifecycle.currentState = Lifecycle.State.RESUMED
...@@ -187,7 +197,7 @@ class AgentNotificationsTest {...@@ -187,7 +197,7 @@ class AgentNotificationsTest {
187 }197 }
188198
189 @Test199 @Test
190 fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() {200 fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() {
191 val grouped = update("group-attention", true) + mapOf(201 val grouped = update("group-attention", true) + mapOf(
192 "alert_title" to "2 agents need attention",202 "alert_title" to "2 agents need attention",
193 "alert_body" to "First thread, Second thread",203 "alert_body" to "First thread, Second thread",
...@@ -195,10 +205,13 @@ class AgentNotificationsTest {...@@ -195,10 +205,13 @@ class AgentNotificationsTest {
195 )205 )
196 lifecycle.currentState = Lifecycle.State.RESUMED206 lifecycle.currentState = Lifecycle.State.RESUMED
197 AgentNotifications.receive(context, grouped)207 AgentNotifications.receive(context, grouped)
208 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" })
198 lifecycle.currentState = Lifecycle.State.CREATED209 lifecycle.currentState = Lifecycle.State.CREATED
199 AgentNotifications.receive(context, grouped)210 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" })
202 }215 }
203216
204 @Test217 @Test
apps/mobile/src/features/agent-awareness/androidNotifications.ts+6
...@@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native";...@@ -5,6 +5,7 @@ import { Linking, Platform } from "react-native";
5interface AndroidAgentNotifications {5interface AndroidAgentNotifications {
6 configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;6 configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
7 clear(): void;7 clear(): void;
8 setThreadOnScreen?(path: string | null): void;
8 openLiveUpdateSettings?(): boolean;9 openLiveUpdateSettings?(): boolean;
9 showShowcaseActivity?(scheme: string, data: Record<string, string>): void;10 showShowcaseActivity?(scheme: string, data: Record<string, string>): void;
10}11}
...@@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void {...@@ -42,6 +43,11 @@ export function clearAndroidAgentNotifications(): void {
42 native?.clear?.();43 native?.clear?.();
43}44}
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
45export function supportsAndroidLiveUpdateSettings(): boolean {51export function supportsAndroidLiveUpdateSettings(): boolean {
46 return Platform.OS === "android" && Number(Platform.Version) >= 36;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,7 +1,8 @@
1import { useEffect, useRef } from "react";1import { useEffect, useLayoutEffect, useRef } from "react";
2import * as Notifications from "expo-notifications";2import * as Notifications from "expo-notifications";
3import { useLinkTo } from "@react-navigation/native";3import { useLinkTo } from "@react-navigation/native";
44
5import { setAndroidThreadOnScreen } from "./androidNotifications";
5import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior";6import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior";
6import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload";7import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload";
7import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";8import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
...@@ -12,7 +13,13 @@ export function useAgentNotificationNavigation(pathname: string): void {...@@ -12,7 +13,13 @@ export function useAgentNotificationNavigation(pathname: string): void {
12 // Read through a ref so the native handler registered once below sees the13 // Read through a ref so the native handler registered once below sees the
13 // current route without re-registering on every navigation.14 // current route without re-registering on every navigation.
14 const deepLinkOnScreen = useRef<string | null>(null);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]);
1623
17 useEffect(() => {24 useEffect(() => {
18 Notifications.setNotificationHandler({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,7 +68,7 @@ vp run push:android:smoke /path/service-account.json /path/device.json completed
6868
69Supported 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.69Supported 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
73With 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.73With 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", () => {...@@ -225,28 +225,31 @@ describe("Android delivery routing", () => {
225 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],225 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],
226 ["completed", "failed", "2 agents finished", "false"],226 ["completed", "failed", "2 agents finished", "false"],
227 ] as const) {227 ] as const) {
228 it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => {228 it.effect(
229 const h = harness();229 `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`,
230 h.current.otherStates = [secondState];230 () => {
231 return Effect.gen(function* () {231 const h = harness();
232 const delivery = yield* FcmDeliveries;232 h.current.otherStates = [secondState];
233 yield* delivery.process(h.job);233 return Effect.gen(function* () {
234 h.current.state = { ...state, phase: firstPhase };234 const delivery = yield* FcmDeliveries;
235 h.current.otherStates = [{ ...secondState, phase: secondPhase }];235 yield* delivery.process(h.job);
236 yield* delivery.process({ ...h.job, state: h.current.state });236 h.current.state = { ...state, phase: firstPhase };
237 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });237 h.current.otherStates = [{ ...secondState, phase: secondPhase }];
238 yield* delivery.process({ ...h.job, state: h.current.state });238 yield* delivery.process({ ...h.job, state: h.current.state });
239 const alerts = h.sent.filter((message) => message.alert);239 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
240 expect(alerts).toHaveLength(1);240 yield* delivery.process({ ...h.job, state: h.current.state });
241 expect(alerts[0]?.data).toMatchObject({241 const alerts = h.sent.filter((message) => message.alert);
242 alert_title: title,242 expect(alerts).toHaveLength(1);
243 alert_body: "Fix notifications, Second thread",243 expect(alerts[0]?.data).toMatchObject({
244 alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink,244 alert_title: title,
245 active,245 alert_body: "Fix notifications, Second thread",
246 });246 alert_path: "/",
247 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);247 active,
248 }).pipe(Effect.provide(h.layer));248 });
249 });249 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);
250 }).pipe(Effect.provide(h.layer));
251 },
252 );
250 }253 }
251254
252 it.effect("filters disabled event types before counting a group", () => {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,7 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts";
22import * as LiveActivities from "./LiveActivities.ts";22import * as LiveActivities from "./LiveActivities.ts";
23import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts";23import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts";
24import * as FcmClient from "./FcmClient.ts";24import * as FcmClient from "./FcmClient.ts";
25import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts";25import { androidActivityData, fitFcmData } from "./fcmPayloads.ts";
26import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts";26import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts";
27import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts";27import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts";
28import {28import {
...@@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: {...@@ -109,7 +109,9 @@ export function androidAlertForAggregate(input: {
109 ),109 ),
110 alert_title: alert.title,110 alert_title: alert.title,
111 alert_body: alert.body,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}
115117