diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f98058066..d4438911eb7bf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -30,12 +30,14 @@ import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, + agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, + subscribeAgentAwarenessRegistrationStatus, updateAgentAwarenessRegistrationPreferences, type AgentAwarenessRegistrationError, } from "./remoteRegistration"; @@ -709,6 +711,115 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + + const stubAndroidDeviceRelay = () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + const registrations: Array<{ + readonly preferences: { readonly notificationsEnabled: boolean }; + }> = []; + 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: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + if (request.url.endsWith("/v1/mobile/devices")) { + registrations.push(await request.json()); + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + return registrations; + }; + + it.effect("does not report notifications ready when its push token lookup fails", () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue( + new Error("Default FirebaseApp is not initialized"), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + // Live Activities register with their own tokens, so the device stays registered. + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(agentAwarenessNotificationsReady()).toBe(false); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(registrations[0]).not.toHaveProperty("pushToken"); + + const onStatusChange = vi.fn(); + subscribeAgentAwarenessRegistrationStatus(onStatusChange); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "android", data: "fcm-token" } as never); + yield* runBackgroundOperations(); + + expect(agentAwarenessNotificationsReady()).toBe(true); + expect(onStatusChange).toHaveBeenCalled(); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + + it.effect("keeps notifications enabled when a refresh cannot read the push token", () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce( + new Error("SERVICE_NOT_AVAILABLE"), + ); + yield* refreshAgentAwarenessRegistration(); + + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(agentAwarenessNotificationsReady()).toBe(true); + + // A lookup that started before a rotation must not replace the rotated token. + let resolveStaleLookup: (token: { type: "android"; data: string }) => void = () => {}; + let markLookupStarted: () => void = () => {}; + const lookupStarted = new Promise((resolve) => { + markLookupStarted = resolve; + }); + vi.mocked(Notifications.getDevicePushTokenAsync).mockImplementationOnce(() => { + markLookupStarted(); + return new Promise((resolve) => { + resolveStaleLookup = resolve; + }); + }); + const staleRefresh = yield* Effect.forkChild(refreshAgentAwarenessRegistration()); + yield* Effect.promise(() => lookupStarted); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "android", data: "rotated-fcm-token" } as never); + yield* runBackgroundOperations(); + resolveStaleLookup({ type: "android", data: "fcm-token" }); + yield* Fiber.join(staleRefresh); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce( + new Error("SERVICE_NOT_AVAILABLE"), + ); + yield* refreshAgentAwarenessRegistration(); + + expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated-fcm-token" }); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it.effect.each(["blocked", "unavailable"] as const)( "keeps Android push delivery registered when the alert channel is %s", (channelState) => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad94..a2b972b0ccc2d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -95,6 +95,9 @@ const registeredActivityPushTokens = new Map(); // repaint, so a slow older response never overwrites newer rows. let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; +let lastNativePushToken: string | null = null; +let registeredWithPushToken = false; +let nativePushTokenRotations = 0; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -102,11 +105,15 @@ export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "register let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; const registrationStatusListeners = new Set<() => void>(); -function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { - if (registrationStatus === next) { +function setRegistrationStatus( + next: AgentAwarenessRegistrationStatus, + withPushToken = registeredWithPushToken, +): void { + if (registrationStatus === next && registeredWithPushToken === withPushToken) { return; } registrationStatus = next; + registeredWithPushToken = withPushToken; for (const listener of registrationStatusListeners) { listener(); } @@ -116,6 +123,12 @@ export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistratio return registrationStatus; } +// Live Activities use their own push tokens, so device registration alone does not mean +// the relay can deliver notifications. +export function agentAwarenessNotificationsReady(): boolean { + return registrationStatus === "registered" && registeredWithPushToken; +} + export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { registrationStatusListeners.add(listener); return () => { @@ -291,12 +304,15 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke ) : true); if (observedPushToken) { + lastNativePushToken = observedPushToken; + nativePushTokenRotations++; return { notificationsEnabled: alertsEnabled, pushToken: observedPushToken, notificationPermissionGranted: permissions.granted, }; } + const rotationsBeforeLookup = nativePushTokenRotations; const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), catch: (cause) => @@ -312,12 +328,18 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke ), Effect.orElseSucceed(() => null), ); - const pushToken = + const lookedUpPushToken = token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; + // A rotation observed while this lookup ran is newer than what the lookup returned. + if (lookedUpPushToken && rotationsBeforeLookup === nativePushTokenRotations) { + lastNativePushToken = lookedUpPushToken; + } + const pushToken = lastNativePushToken; + // A missing token must not disable notifications the relay accepted. return { - notificationsEnabled: alertsEnabled && pushToken !== null, + notificationsEnabled: alertsEnabled, pushToken, notificationPermissionGranted: permissions.granted, }; @@ -405,7 +427,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith persisted.signature === signature && !needsAndroidReplay ) { - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); return; } @@ -421,7 +443,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith return; } if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, @@ -855,7 +877,10 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; + registeredWithPushToken = false; androidDeviceReplayedAt = null; + lastNativePushToken = null; + nativePushTokenRotations = 0; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index cabe229f9a262..acd24efb8dc19 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -34,6 +34,7 @@ import { import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { + agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, @@ -108,6 +109,14 @@ function useDeviceRegistered(): boolean { return status === "registered"; } +function useNotificationsReady(): boolean { + return useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + agentAwarenessNotificationsReady, + () => false, + ); +} + export function SettingsNotificationsRouteScreen() { if (!hasCloudPublicConfig()) { return ( @@ -160,6 +169,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityOverride({ status, basis: liveActivityBasis }); const liveActivityWriteInFlight = useRef(false); const deviceRegistered = useDeviceRegistered(); + const notificationsReady = useNotificationsReady(); const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.liveActivitiesEnabled !== false : true; @@ -192,7 +202,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } if (result.value.type === "granted") { setNotificationStatus("enabled"); - if (getAgentAwarenessRegistrationStatus() === "registered") { + if (agentAwarenessNotificationsReady()) { Dialog.alert("Notifications enabled", "Agent notifications are enabled for this device."); } else { Dialog.alert( @@ -434,7 +444,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } subtitle={agentAwarenessSubtitle} value={ - agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered + agentAwarenessPushAvailable && notificationStatus === "enabled" && notificationsReady } onValueChange={handleDeviceNotificationsChange} /> diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts new file mode 100644 index 0000000000000..d6b7dfe89500d --- /dev/null +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import * as Devices from "./Devices.ts"; + +const deviceRow = (pushToken: string | null) => ({ + deviceId: "device-1", + label: "Pixel 9", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 36, + appVersion: "2.2.0", + pushToken, + preferences: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + updatedAt: "2026-10-28T00:00:00.000Z", +}); + +describe("Devices", () => { + it.effect.each([ + [null, false], + ["fcm-token", true], + ] as const)("lists notifications for push token %s as enabled: %s", ([pushToken, enabled]) => { + const fakeDb = { + select: () => ({ + from: () => ({ where: () => Effect.succeed([deviceRow(pushToken)]) }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const [device] = yield* devices.listForUser({ userId: "user-1" }); + + expect(device?.notifications.enabled).toBe(enabled); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index a35fb4aaf8e4b..21f05b2c61fd2 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -222,6 +222,7 @@ const make = Effect.gen(function* () { iosMajorVersion: relayMobileDevices.iosMajorVersion, androidApiLevel: relayMobileDevices.androidApiLevel, appVersion: relayMobileDevices.appVersion, + pushToken: relayMobileDevices.pushToken, preferences: relayMobileDevices.preferencesJson, updatedAt: relayMobileDevices.updatedAt, }) @@ -240,7 +241,8 @@ const make = Effect.gen(function* () { androidApiLevel: row.androidApiLevel, appVersion: row.appVersion, notifications: { - enabled: row.preferences.notificationsEnabled, + // Clients keep the preference on while the device has no token; nothing is delivered then. + enabled: row.preferences.notificationsEnabled && row.pushToken !== null, notifyOnApproval: row.preferences.notifyOnApproval, notifyOnInput: row.preferences.notifyOnInput, notifyOnCompletion: row.preferences.notifyOnCompletion,