From 5980125720413fe88471484cf61286a6d2635fd8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 15:34:52 +0000 Subject: [PATCH 01/10] test(mobile): reproduce push-token failure reported as registered (#26530) These tests reproduce #26530 on Android: when the native push token lookup rejects with permission granted, registration still reports "registered", and a refresh on an already registered device overwrites the relay's notification preference with notificationsEnabled: false. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..836a19c4ceb25 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -656,6 +656,81 @@ 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 a device registered 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(); + + expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + expect(registrations).not.toContainEqual( + expect.objectContaining({ + preferences: expect.objectContaining({ notificationsEnabled: false }), + }), + ); + }).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(getAgentAwarenessRegistrationStatus()).toBe("registered"); + }).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({ From de889cb365780ce8e840fc0484fc61e0a409113d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 15:37:17 +0000 Subject: [PATCH 02/10] fix(mobile): push-token failures no longer report notifications as registered With notification permission granted, a failed native push token lookup was swallowed and the device registered with notificationsEnabled: false, so the app showed "registered" and a refresh overwrote the relay's accepted preference. The lookup failure, or a token that is empty or for the wrong platform, now fails registration as read-native-push-token, so the existing failure status, logging and foreground/session-refresh retries apply. Fixes pingdotgg/t3code#26530 Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 19 +++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 21 ++++++++----------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 836a19c4ceb25..ff2daf0268acf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -705,6 +705,25 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + it.effect("does not report a device registered when its push token is empty", () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: " ", + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + expect(registrations).toEqual([]); + }).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({ diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..633c40ca2be83 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -269,19 +269,16 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke operation: "read-native-push-token", cause, }), - }).pipe( - Effect.tapError((error) => - Effect.sync(() => { - logRegistrationError("native push token lookup failed", error); - }), - ), - Effect.orElseSucceed(() => null), - ); + }); const pushToken = - token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 - ? token.data.trim() - : null; - return { notificationsEnabled: pushToken !== null, pushToken }; + token.type === Platform.OS && typeof token.data === "string" ? token.data.trim() : ""; + if (!pushToken) { + return yield* new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause: new Error(`Native push token lookup returned an unusable ${token.type} token.`), + }); + } + return { notificationsEnabled: true, pushToken }; }, ); From 9ef3ffe59d201ae6234bca0c8452fa8668334dd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 15:52:06 +0000 Subject: [PATCH 03/10] fix(mobile): reuse the last push token when a later lookup fails A registered device whose token lookup failed could no longer save a preference change. Reuse the token read earlier in this process instead. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 28 ++++++++++++++ .../agent-awareness/remoteRegistration.ts | 38 ++++++++++++++----- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index ff2daf0268acf..cc77f427f529e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -750,6 +750,34 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + it.effect("saves a preference change when the push token cannot be re-read", () => { + 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(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce( + new Error("SERVICE_NOT_AVAILABLE"), + ); + yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }); + + expect(registrations).toMatchObject([ + { preferences: { notificationsEnabled: true } }, + { + pushToken: "fcm-token", + preferences: { notificationsEnabled: true, liveActivitiesEnabled: 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 633c40ca2be83..2316b20bbb3a9 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -84,6 +84,7 @@ const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); let androidDeviceReplayedAt: number | null = null; +let lastNativePushToken: string | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -260,24 +261,40 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke return { notificationsEnabled: false, pushToken: null }; } if (observedPushToken) { + lastNativePushToken = observedPushToken; return { notificationsEnabled: true, pushToken: observedPushToken }; } - const token = yield* Effect.tryPromise({ + const pushToken = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), catch: (cause) => new AgentAwarenessOperationError({ operation: "read-native-push-token", cause, }), - }); - const pushToken = - token.type === Platform.OS && typeof token.data === "string" ? token.data.trim() : ""; - if (!pushToken) { - return yield* new AgentAwarenessOperationError({ - operation: "read-native-push-token", - cause: new Error(`Native push token lookup returned an unusable ${token.type} token.`), - }); - } + }).pipe( + Effect.flatMap((token) => { + const data = + token.type === Platform.OS && typeof token.data === "string" ? token.data.trim() : ""; + return data + ? Effect.succeed(data) + : Effect.fail( + new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause: new Error( + `Native push token lookup returned an unusable ${token.type} token.`, + ), + }), + ); + }), + // Registering without a token would disable notifications the relay already accepted. + Effect.catch((error) => { + const fallback = lastNativePushToken; + if (!fallback) return Effect.fail(error); + logRegistrationError("native push token lookup failed; reusing the last token", error); + return Effect.succeed(fallback); + }), + ); + lastNativePushToken = pushToken; return { notificationsEnabled: true, pushToken }; }, ); @@ -799,6 +816,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { pendingDeviceRegistration = null; registrationStatus = "unknown"; androidDeviceReplayedAt = null; + lastNativePushToken = null; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); From 50e69519476b823d09138a8c995180fe6c4b28cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:03:52 +0000 Subject: [PATCH 04/10] fix(mobile): a tokenless registration is not kept as notifications enabled Signing in before granting permission registers the device with notifications off. If the token lookup then fails once permission is granted, the failed refresh kept that "registered" status, so Settings reported notifications as enabled. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 27 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index cc77f427f529e..1cdbe4944e736 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -750,6 +750,33 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + it.effect( + "does not report notifications enabled after permission is granted without a token", + () => { + const registrations = stubAndroidDeviceRelay(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as never); + 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(); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: false } }]); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + yield* refreshAgentAwarenessRegistration(); + + expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }, + ); + it.effect("saves a preference change when the push token cannot be re-read", () => { const registrations = stubAndroidDeviceRelay(); 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 2316b20bbb3a9..eee77c1854732 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -289,7 +289,11 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke // Registering without a token would disable notifications the relay already accepted. Effect.catch((error) => { const fallback = lastNativePushToken; - if (!fallback) return Effect.fail(error); + if (!fallback) { + // Any registration accepted so far in this process had notifications off. + setRegistrationStatus("failed"); + return Effect.fail(error); + } logRegistrationError("native push token lookup failed; reusing the last token", error); return Effect.succeed(fallback); }), From bc1d62c74cdb3859d53e6603eadbd88683f4b1b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:15:20 +0000 Subject: [PATCH 05/10] fix(mobile): a missing push token keeps the relay registration but reports failure Failing the whole registration on a token lookup error blocked activity preference writes and the iOS device row Live Activities depend on. Register without a token instead (the relay keeps any token it holds), keep notifications enabled since permission is granted, and report the registration as failed until the relay has a token. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 22 ++++----- .../agent-awareness/remoteRegistration.ts | 49 ++++++++----------- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 1cdbe4944e736..215384253f22a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -694,30 +694,28 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); - expect(registrations).not.toContainEqual( - expect.objectContaining({ - preferences: expect.objectContaining({ notificationsEnabled: false }), - }), - ); + expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); + expect(registrations[0]).not.toHaveProperty("pushToken"); }).pipe( Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), Effect.provide(layerRelayTest), ); }); - it.effect("does not report a device registered when its push token is empty", () => { + it.effect("saves a preference change when no push token can be read", () => { const registrations = stubAndroidDeviceRelay(); - vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ - type: "android", - data: " ", - }); + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue( + new Error("SERVICE_NOT_AVAILABLE"), + ); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); return Effect.gen(function* () { yield* runBackgroundOperations(); + yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }); - expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); - expect(registrations).toEqual([]); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true, liveActivitiesEnabled: true }, + }); }).pipe( Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), Effect.provide(layerRelayTest), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index eee77c1854732..72adc0a4825c7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -264,7 +264,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke lastNativePushToken = observedPushToken; return { notificationsEnabled: true, pushToken: observedPushToken }; } - const pushToken = yield* Effect.tryPromise({ + const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), catch: (cause) => new AgentAwarenessOperationError({ @@ -272,33 +272,19 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke cause, }), }).pipe( - Effect.flatMap((token) => { - const data = - token.type === Platform.OS && typeof token.data === "string" ? token.data.trim() : ""; - return data - ? Effect.succeed(data) - : Effect.fail( - new AgentAwarenessOperationError({ - operation: "read-native-push-token", - cause: new Error( - `Native push token lookup returned an unusable ${token.type} token.`, - ), - }), - ); - }), - // Registering without a token would disable notifications the relay already accepted. - Effect.catch((error) => { - const fallback = lastNativePushToken; - if (!fallback) { - // Any registration accepted so far in this process had notifications off. - setRegistrationStatus("failed"); - return Effect.fail(error); - } - logRegistrationError("native push token lookup failed; reusing the last token", error); - return Effect.succeed(fallback); - }), + Effect.tapError((error) => + Effect.sync(() => { + logRegistrationError("native push token lookup failed", error); + }), + ), + Effect.orElseSucceed(() => null), ); + const pushToken = + token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 + ? token.data.trim() + : lastNativePushToken; lastNativePushToken = pushToken; + // Permission is granted: a missing token must not disable notifications the relay accepted. return { notificationsEnabled: true, pushToken }; }, ); @@ -336,6 +322,13 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { ].join("|"); } +// Notifications are not set up until the relay holds a token for them. +function acceptedRegistrationStatus( + body: RelayDeviceRegistrationRequest, +): AgentAwarenessRegistrationStatus { + return body.preferences.notificationsEnabled && !body.pushToken ? "failed" : "registered"; +} + const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* ( body: RelayDeviceRegistrationRequest, expectedGeneration: number, @@ -382,7 +375,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith persisted.signature === signature && !needsAndroidReplay ) { - setRegistrationStatus("registered"); + setRegistrationStatus(acceptedRegistrationStatus(payload)); return; } @@ -398,7 +391,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith return; } if (body.platform === "android") androidDeviceReplayedAt = Date.now(); - setRegistrationStatus("registered"); + setRegistrationStatus(acceptedRegistrationStatus(payload)); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, From 7b0046b308baaffc13e09f35c5c92b66d87779bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:28:33 +0000 Subject: [PATCH 06/10] fix(relay): list a device without a push token as notifications off Mobile now keeps the notification preference on while it cannot read its push token, so the device list must not report such a device as enabled. Co-Authored-By: Claude Opus 5.5 --- infra/relay/src/agentActivity/Devices.test.ts | 47 +++++++++++++++++++ infra/relay/src/agentActivity/Devices.ts | 4 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 infra/relay/src/agentActivity/Devices.test.ts 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, From fe445b87c279d2063683d7082af3ee87c5c8df14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:34:21 +0000 Subject: [PATCH 07/10] test(mobile): drop unrelated blank line from registration tests AI-assisted; verified with the tests and tools listed above. --- .../src/features/agent-awareness/remoteRegistration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c733c25cab3ec..d5fc119a957ab 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -846,7 +846,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); - it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ From 2799d1369516d2a1bbbc6bc51a62fc5289844e4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:45:30 +0000 Subject: [PATCH 08/10] fix(mobile): a token lookup that overlaps a rotation keeps the rotated token Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 26 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 14 +++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d5fc119a957ab..08f9e89b3a3a8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -775,6 +775,32 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + // 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), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ccfa0a5d775cc..a47693ffd58b8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -96,6 +96,7 @@ const registeredActivityPushTokens = new Map(); let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; let lastNativePushToken: string | null = null; +let nativePushTokenRotations = 0; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -293,12 +294,14 @@ 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) => @@ -314,11 +317,15 @@ 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() - : lastNativePushToken; - lastNativePushToken = pushToken; + : 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, @@ -868,6 +875,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatus = "unknown"; androidDeviceReplayedAt = null; lastNativePushToken = null; + nativePushTokenRotations = 0; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); From 9a995aa3692869f95ae784c00fc95af0d3faf052 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:58:10 +0000 Subject: [PATCH 09/10] fix(mobile): a tokenless device stays registered for Live Activities Report notification readiness separately from device registration, which Live Activities rely on and which do not need the device push token. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 9 +++++--- .../agent-awareness/remoteRegistration.ts | 21 +++++++++++-------- .../SettingsNotificationsRouteScreen.tsx | 3 ++- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 08f9e89b3a3a8..c379eccdf8381 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -30,6 +30,7 @@ import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, + agentAwarenessNotificationsReady, getAgentAwarenessRegistrationStatus, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, @@ -736,7 +737,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return registrations; }; - it.effect("does not report a device registered when its push token lookup fails", () => { + 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"), @@ -746,7 +747,9 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); - expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + // 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"); }).pipe( @@ -774,7 +777,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshAgentAwarenessRegistration(); expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]); - expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + 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 = () => {}; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a47693ffd58b8..39c39289981a7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -96,6 +96,7 @@ const registeredActivityPushTokens = new Map(); 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; @@ -118,6 +119,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 () => { @@ -368,13 +375,6 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { ].join("|"); } -// Notifications are not set up until the relay holds a token for them. -function acceptedRegistrationStatus( - body: RelayDeviceRegistrationRequest, -): AgentAwarenessRegistrationStatus { - return body.preferences.notificationsEnabled && !body.pushToken ? "failed" : "registered"; -} - const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* ( body: RelayDeviceRegistrationRequest, expectedGeneration: number, @@ -423,7 +423,8 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith persisted.signature === signature && !needsAndroidReplay ) { - setRegistrationStatus(acceptedRegistrationStatus(payload)); + registeredWithPushToken = Boolean(payload.pushToken); + setRegistrationStatus("registered"); return; } @@ -439,7 +440,8 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith return; } if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; - setRegistrationStatus(acceptedRegistrationStatus(payload)); + registeredWithPushToken = Boolean(payload.pushToken); + setRegistrationStatus("registered"); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, @@ -873,6 +875,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; + registeredWithPushToken = false; androidDeviceReplayedAt = null; lastNativePushToken = null; nativePushTokenRotations = 0; diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index cabe229f9a262..44a63740887ec 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, @@ -192,7 +193,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( From 2f29a1450fc3fead66f127f889868ccf4a12bfc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:11:40 +0000 Subject: [PATCH 10/10] fix(mobile): Device Notifications shows off until the relay holds a push token The switch read device registration, which stays registered for Live Activities when no push token is available. It now reads notification readiness, and a change in readiness notifies subscribers. Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/remoteRegistration.test.ts | 10 ++++++++++ .../features/agent-awareness/remoteRegistration.ts | 14 ++++++++------ .../settings/SettingsNotificationsRouteScreen.tsx | 11 ++++++++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c379eccdf8381..d4438911eb7bf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -37,6 +37,7 @@ import { registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, + subscribeAgentAwarenessRegistrationStatus, updateAgentAwarenessRegistrationPreferences, type AgentAwarenessRegistrationError, } from "./remoteRegistration"; @@ -752,6 +753,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { 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), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 39c39289981a7..a2b972b0ccc2d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -105,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(); } @@ -423,8 +427,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith persisted.signature === signature && !needsAndroidReplay ) { - registeredWithPushToken = Boolean(payload.pushToken); - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); return; } @@ -440,8 +443,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith return; } if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; - registeredWithPushToken = Boolean(payload.pushToken); - setRegistrationStatus("registered"); + setRegistrationStatus("registered", Boolean(payload.pushToken)); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 44a63740887ec..acd24efb8dc19 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -109,6 +109,14 @@ function useDeviceRegistered(): boolean { return status === "registered"; } +function useNotificationsReady(): boolean { + return useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + agentAwarenessNotificationsReady, + () => false, + ); +} + export function SettingsNotificationsRouteScreen() { if (!hasCloudPublicConfig()) { return ( @@ -161,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; @@ -435,7 +444,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } subtitle={agentAwarenessSubtitle} value={ - agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered + agentAwarenessPushAvailable && notificationStatus === "enabled" && notificationsReady } onValueChange={handleDeviceNotificationsChange} />