Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -187,18 +197,21 @@ 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",
"alert_path" to "/",
)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): void;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string | null>(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({
Comment thread
juliusmarminge marked this conversation as resolved.
Expand Down
2 changes: 1 addition & 1 deletion docs/operations/android-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
47 changes: 25 additions & 22 deletions infra/relay/src/agentActivity/FcmDeliveries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 4 additions & 2 deletions infra/relay/src/agentActivity/FcmDeliveries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "/",
};
}

Expand Down
Loading