diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index c4aff3c331179..7f4d8c0e691ae 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -115,7 +115,7 @@ internal class ActivityPresentation(data: Map, private val activ } val chip = when { - !active -> null + !active -> if (phase == ActivityPhase.FAILED) "Failed" else "Done" phase == null -> data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active" phase == ActivityPhase.RUNNING && activeCount > 1 -> "${if (activeCount > 9) "9+" else activeCount} live" 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 19d14f450f526..fdfd144ebb9cc 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 @@ -297,21 +297,21 @@ object AgentNotifications { ) val presentation = ActivityPresentation(data, active) val openThread = contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID) + // Retained results still need Android's ongoing flag to remain promoted. + val ongoing = active || Build.VERSION.SDK_INT >= 36 val builder = base(context, ACTIVITY_CHANNEL) - .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) + .setOngoing(ongoing).setOnlyAlertOnce(true).setSilent(true) .setTimeoutAfter(remainingMs) // Live Updates must remain uncolorized to qualify for promotion. .setColorized(false) - .setRequestPromotedOngoing(active) - .setShortCriticalText(presentation.chip) + .setRequestPromotedOngoing(ongoing) + .setShortCriticalText(if (ongoing) presentation.chip else null) .setContentIntent(openThread) .setDeleteIntent(dismissIntent) presentation.applyTo(builder, context) val action = presentation.action - if (action != null) { - if (openThread != null) builder.addAction(0, action, openThread) - builder.addAction(0, "Dismiss", dismissIntent) - } + if (action != null && openThread != null) builder.addAction(0, action, openThread) + if (ongoing) builder.addAction(0, "Dismiss", dismissIntent) manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build()) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Notification timeouts were added in API 26. One inexact alarm also 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 59ab4d6651e1a..41224273d2ec4 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 @@ -3,6 +3,7 @@ package expo.modules.t3agentnotifications import android.app.Activity import android.app.AlarmManager import android.app.Application +import android.app.NotificationChannel import android.app.Notification import android.app.NotificationManager import android.content.ComponentName @@ -264,7 +265,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() { } @Test - fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() { + fun finishedCardIsRetainedSilentlyAndExpiresAtTheOriginalDeadline() { lifecycle.currentState = Lifecycle.State.RESUMED val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L val finished = update("finished", false) + mapOf( @@ -275,7 +276,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() { AgentNotifications.receive(context, finished) val card = manager.activeNotifications.single().notification assertEquals("Agent work failed", card.extras.getString(Notification.EXTRA_TITLE)) - assertFalse(card.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals(Build.VERSION.SDK_INT >= 36, card.flags and Notification.FLAG_ONGOING_EVENT != 0) assertTimeout(card, 1..15 * 60 * 1000L) AgentNotifications.receive( context, @@ -413,10 +414,22 @@ class AgentNotificationsTest : AgentNotificationsFixture() { "android.app.Notification\$BigTextStyle", card.extras.getString(Notification.EXTRA_TEMPLATE) ) - assertEquals(active, NotificationCompat.isRequestPromotedOngoing(card)) + assertEquals( + active || Build.VERSION.SDK_INT >= 36, + NotificationCompat.isRequestPromotedOngoing(card) + ) if (!active) { - assertEquals(null, card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) - assertEquals(0, card.actions?.size ?: 0) + assertEquals( + if (Build.VERSION.SDK_INT < 36) { + null + } else if (phase == "failed") { + "Failed" + } else { + "Done" + }, + card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + assertEquals(if (Build.VERSION.SDK_INT >= 36) 1 else 0, card.actions?.size ?: 0) } } } @@ -566,7 +579,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() { } @Test - fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() { + fun liveUpdateChipChangesWithActivityAndShowsDoneOnCompletion() { lifecycle.currentState = Lifecycle.State.RESUMED AgentNotifications.receive(context, update("work", true)) assertEquals( @@ -590,9 +603,13 @@ class AgentNotificationsTest : AgentNotificationsFixture() { ) ) val finishedCard = manager.activeNotifications.single().notification - assertEquals(null, finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) - assertFalse(NotificationCompat.isRequestPromotedOngoing(finishedCard)) - assertFalse(finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0) + val promoted = Build.VERSION.SDK_INT >= 36 + assertEquals( + if (promoted) "Done" else null, + finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + assertEquals(promoted, NotificationCompat.isRequestPromotedOngoing(finishedCard)) + assertEquals(promoted, finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0) } @Test @@ -635,6 +652,92 @@ class AgentNotificationsTest : AgentNotificationsFixture() { } } +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 33, 36], manifest = Config.NONE) +class AgentNotificationChannelsTest { + @Test + fun mutedAlertChannelKeepsTheSameActivityThroughCompletionUntilDismissedOrExpired() { + val context = RuntimeEnvironment.getApplication() + val manager = context.getSystemService(NotificationManager::class.java) + shadowOf(manager).setNotificationsEnabled(true) + AgentNotifications.clear(context) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + val running = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to System.currentTimeMillis().toString(), + "active" to "true", + "activity_title" to "Agent activity" + ) + manager.createNotificationChannel( + NotificationChannel("agent-alerts", "Agent alerts", NotificationManager.IMPORTANCE_NONE) + ) + AgentNotifications.receive(context, running) + val initialCard = manager.activeNotifications.single() + assertEquals("t3-agent-activity", initialCard.tag) + + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + AgentNotifications.receive( + context, + running + mapOf( + "activity_phase" to "waiting_for_approval", + "activity_attention_count" to "1" + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals("Approve", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + + val finished = running + mapOf( + "active" to "false", + "activity_phase" to "completed", + "activity_line_0" to "Done\tTest thread\tTest project", + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString() + ) + AgentNotifications.receive(context, finished) + AgentNotifications.receive(context, finished) + val completedCard = manager.activeNotifications.single() + assertEquals(initialCard.id, completedCard.id) + assertEquals(initialCard.tag, completedCard.tag) + assertEquals("agent-activity", completedCard.notification.channelId) + assertEquals( + "Test thread", + completedCard.notification.extras.getString(Notification.EXTRA_TITLE) + ) + assertEquals( + "Done Test project", + completedCard.notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString() + ) + val promoted = Build.VERSION.SDK_INT >= 36 + assertEquals(promoted, NotificationCompat.isRequestPromotedOngoing(completedCard.notification)) + assertEquals( + promoted, + completedCard.notification.flags and Notification.FLAG_ONGOING_EVENT != 0 + ) + assertEquals( + if (promoted) "Done" else null, + completedCard.notification.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + + if (promoted) { + val dismiss = completedCard.notification.actions.single() + assertEquals("Dismiss", dismiss.title.toString()) + AgentActivityDismissReceiver().onReceive(context, shadowOf(dismiss.actionIntent).savedIntent) + } else { + AgentNotifications.dismiss(context) + } + AgentNotifications.receive(context, finished) + assertTrue(manager.activeNotifications.isEmpty()) + + AgentNotifications.receive(context, running) + assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + AgentNotifications.receive( + context, + finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString()) + ) + assertTrue(manager.activeNotifications.isEmpty()) + } +} + @RunWith(RobolectricTestRunner::class) @Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) class AgentNotificationsCompletionTest : AgentNotificationsFixture() { diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 858e0facdf33d..c66ed4c030a93 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -21,11 +21,19 @@ export function makeRelayDeviceRegistrationRequest( readonly preferences: Preferences; } & ( | { readonly platform?: "ios"; readonly iosMajorVersion: number } - | { readonly platform: "android"; readonly androidApiLevel: number } + | { + readonly platform: "android"; + readonly androidApiLevel: number; + readonly notificationPermissionGranted: boolean; + } ), ): RelayDeviceRegistrationRequest { const pushAvailable = supportsAgentAwarenessPush(); - const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false; + const liveActivitiesEnabled = + pushAvailable && + input.preferences.liveActivitiesEnabled !== false && + (input.platform !== "android" || + (input.notificationPermissionGranted && Boolean(input.pushToken))); return { deviceId: input.deviceId, label: input.label, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c58bc492b8bd5..6238d5cdfd66d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -114,6 +114,8 @@ vi.mock("../../state/server", () => ({ })); vi.mock("expo-notifications", () => ({ + AndroidImportance: { NONE: 0, HIGH: 4 }, + getNotificationChannelAsync: vi.fn(() => Promise.resolve(null)), addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })), @@ -248,6 +250,11 @@ describe("makeRelayDeviceRegistrationRequest", () => { type: "ios", data: "apns-token", }); + vi.mocked(Notifications.getPermissionsAsync).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + granted: true, + } as Awaited>); + vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null); vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); @@ -260,6 +267,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false }); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); @@ -634,6 +642,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { it.effect.each(["ios", "android"] as const)( "does not enable %s notifications when a token rotates after permission is revoked", (os) => { + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true }); vi.spyOn(Platform, "OS", "get").mockReturnValue(os); vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ @@ -660,7 +669,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: true }, + preferences: { notificationsEnabled: true, liveActivitiesEnabled: true }, }); vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ granted: false, @@ -669,9 +678,21 @@ describe("makeRelayDeviceRegistrationRequest", () => { listener({ type: os, data: "rotated" }); yield* runBackgroundOperations(); expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: false }, + preferences: { notificationsEnabled: false, liveActivitiesEnabled: os === "ios" }, }); - expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + if (os === "android") { + expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated" }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + yield* refreshAgentAwarenessRegistration(); + expect(registrations.at(-1)).toMatchObject({ + pushToken: "initial", + preferences: { notificationsEnabled: false, liveActivitiesEnabled: false }, + }); + } else { + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + } }).pipe( Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), Effect.provide( @@ -682,6 +703,71 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + it.effect.each(["blocked", "unavailable"] as const)( + "keeps Android push delivery registered when the alert channel is %s", + (channelState) => { + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true }); + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue({ + id: "agent-alerts", + name: "Agent alerts", + importance: Notifications.AndroidImportance.NONE, + } as Awaited>); + if (channelState === "unavailable") { + vi.mocked(Notifications.getNotificationChannelAsync).mockRejectedValue( + new Error("Android channel lookup failed"), + ); + } + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + pushToken: "fcm-token", + preferences: { notificationsEnabled: false, liveActivitiesEnabled: true }, + }); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "android", data: "rotated-fcm-token" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + pushToken: "rotated-fcm-token", + preferences: { notificationsEnabled: false, liveActivitiesEnabled: true }, + }); + + vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null); + yield* refreshAgentAwarenessRegistration(); + expect(registrations.at(-1)).toMatchObject({ + pushToken: "fcm-token", + preferences: { notificationsEnabled: true }, + }); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }, + ); it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index caed74e42c488..ec712608fad94 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -261,7 +261,7 @@ function iosMajorVersion(): number { const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushTokenRegistration")( function* (observedPushToken?: string) { if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) { - return { notificationsEnabled: false, pushToken: null }; + return { notificationsEnabled: false, pushToken: null, notificationPermissionGranted: false }; } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), @@ -271,11 +271,31 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke cause, }), }); - if (!permissions.granted) { - return { notificationsEnabled: false, pushToken: null }; + // Android FCM data delivery is independent of notification presentation permission. + if (!permissions.granted && Platform.OS !== "android") { + return { notificationsEnabled: false, pushToken: null, notificationPermissionGranted: false }; } + const alertsEnabled = + permissions.granted && + (Platform.OS === "android" && Number(Platform.Version) >= 26 + ? yield* Effect.tryPromise({ + try: () => Notifications.getNotificationChannelAsync("agent-alerts"), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-notification-permissions", + cause, + }), + }).pipe( + Effect.map((channel) => channel?.importance !== Notifications.AndroidImportance.NONE), + Effect.orElseSucceed(() => false), + ) + : true); if (observedPushToken) { - return { notificationsEnabled: true, pushToken: observedPushToken }; + return { + notificationsEnabled: alertsEnabled, + pushToken: observedPushToken, + notificationPermissionGranted: permissions.granted, + }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), @@ -296,7 +316,11 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; - return { notificationsEnabled: pushToken !== null, pushToken }; + return { + notificationsEnabled: alertsEnabled && pushToken !== null, + pushToken, + notificationPermissionGranted: permissions.granted, + }; }, ); @@ -687,7 +711,11 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* Constants.deviceName?.trim() || (Platform.OS === "android" ? "Android device" : "iOS device"), ...(Platform.OS === "android" - ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) } + ? { + platform: "android" as const, + androidApiLevel: Number(Platform.Version), + notificationPermissionGranted: pushTokenRegistration.notificationPermissionGranted, + } : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }), appVersion: Constants.expoConfig?.version, ...(bundleId ? { bundleId } : {}), diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 1a8c13fcc853c..992efd5b7b29a 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -4,6 +4,8 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 and newer, or **Ongoing Agent Activity** on older Android versions to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off the same switch to stop future cards. +For activity without ordinary alerts on Android 8 or newer, leave **Ongoing agent activity** enabled and turn off **Agent alerts** in Android's notification settings for T3 Code. Activity updates continue, and the same card shows the finished result without a separate alert. On Android 16 and newer, the finished result remains a Live Update until dismissed or expired. Keep app-wide notification permission and **Live updates** enabled so Android can display it. + On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 21df615900f30..71cde83149284 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -508,25 +508,31 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - it.effect("honors disabled alert preferences while continuing ongoing activity", () => { - const h = harness(); - h.current.state = { ...state, phase: "waiting_for_approval" }; - h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent[0]?.data.active).toBe("true"); - expect(h.sent[0]?.data.alert_id).toBeUndefined(); + it.effect( + "continues ongoing activity when alerts are disabled, with or without a previous card", + () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; h.current.target.preferences_json = encodeJson({ ...preferences, notificationsEnabled: false, }); - h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent[1]?.data.active).toBe("false"); - expect(h.sent[1]?.data.alert_id).toBeUndefined(); - }).pipe(Effect.provide(h.layer)); - }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.data.active).toBe("true"); + expect(h.sent[0]?.alert).toBe(false); + expect(h.sent[0]?.data.alert_id).toBeUndefined(); + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[1]?.data.active).toBe("true"); + expect(h.sent[1]?.data.alert_id).toBeUndefined(); + expect(h.marked.at(-1)?.kind).toBe("live_activity_update"); + }).pipe(Effect.provide(h.layer)); + }, + ); it.effect("replays an empty card to repair an orphan without a delivery baseline", () => { const h = harness(); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 1c6cc0a6b71ff..d2795a90a88e5 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -281,10 +281,7 @@ const make = Effect.gen(function* () { alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); } } - const displayedAggregate = - preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled - ? aggregate - : null; + const displayedAggregate = preferences.value.liveActivitiesEnabled ? aggregate : null; const active = (displayedAggregate?.activeCount ?? 0) > 0; if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; const data: Record = {