From 5a6517ba56aa467bc6fa3c9f6ac36aaa3fe562bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 06:51:57 +0000 Subject: [PATCH 1/5] test(mobile): reproduce #27412 live activity toggle failing on link proof Enabling Live Activities fails and rolls back the device preference when a saved host refuses the environment link proof ("Invalid managed endpoint origin."), which every LAN or tunnel host does for a phone. This test reproduces #27412 and fails on main. Co-Authored-By: Claude Opus 5.5 --- .../liveActivityPreferences.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 3f1fe66908e93..41b2eb9c3a25d 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -1,6 +1,7 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import type { EnvironmentId } from "@t3tools/contracts"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import * as Layer from "effect/Layer"; @@ -158,4 +159,31 @@ describe("liveActivityPreferences", () => { expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); }).pipe(Effect.provide(layerTest)); }); + + it.effect("enables updates when a host refuses an environment link proof", () => { + vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => + Effect.fail( + new CloudEnvironmentLinkError({ + message: "Could not obtain environment link proof: Invalid managed endpoint origin.", + }), + ), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + setLiveActivityUpdatesEnabled({ + enabled: true, + previousEnabled: false, + clerkToken: "clerk-token", + connections: [connection], + canConfigureEnvironment: () => true, + }), + ); + + expect(exit).toStrictEqual(Exit.void); + expect(vi.mocked(updateAgentAwarenessRegistrationPreferences).mock.lastCall).toEqual([ + { liveActivitiesEnabled: true }, + ]); + }).pipe(Effect.provide(layerTest)); + }); }); From ac1c1405079d66a41c4694ac2bb35f8848ac8847 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 06:56:21 +0000 Subject: [PATCH 2/5] fix(mobile): live activity toggle no longer re-links saved environments Turning Live Activity updates (Android ongoing activity) on or off re-linked every saved environment through the host's link-proof endpoint, which only accepts loopback hosts. A real phone reaches the host over LAN, Tailscale or the tunnel, so the toggle always failed with "Invalid managed endpoint origin." The relay already gates Live Activity delivery on each device's registration preference, and desktop, web and CLI links enable activities at the link level. The toggle now only updates this device's registration preference. The unused mobile environment-link module and its Clerk token plumbing are gone. Fixes pingdotgg/t3code#27412 Co-Authored-By: Claude Opus 5.5 --- .../liveActivityPreferences.test.ts | 189 ------------ .../liveActivityPreferences.ts | 80 ------ .../remoteRegistration.test.ts | 41 +++ .../features/cloud/linkEnvironment.test.ts | 268 ------------------ .../src/features/cloud/linkEnvironment.ts | 239 ---------------- .../src/features/cloud/managedRelayState.ts | 7 - .../SettingsNotificationsRouteScreen.tsx | 85 +----- 7 files changed, 54 insertions(+), 855 deletions(-) delete mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts delete mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts delete mode 100644 apps/mobile/src/features/cloud/linkEnvironment.test.ts delete mode 100644 apps/mobile/src/features/cloud/linkEnvironment.ts diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts deleted file mode 100644 index 41b2eb9c3a25d..0000000000000 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { beforeEach, vi } from "vite-plus/test"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/http"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobileStorage from "../../persistence/mobile-storage"; -import { - CloudEnvironmentLinkError, - linkEnvironmentToCloudWithPreference, -} from "../cloud/linkEnvironment"; -import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; - -vi.mock("expo-secure-store", () => ({ - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), -})); - -vi.mock("react-native", () => ({ - Platform: { OS: "ios" }, -})); - -vi.mock("expo-constants", () => ({ - default: { expoConfig: {} }, -})); - -vi.mock("expo-device", () => ({})); - -vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({ - ...(await importOriginal()), - linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), -})); - -vi.mock("./remoteRegistration", () => ({ - updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), -})); - -const connection: SavedRemoteConnection = { - environmentId: "env-1" as EnvironmentId, - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example.test/", - displayUrl: "https://desktop.example.test/", - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - bearerToken: "local-bearer", -}; - -const layerTest = Layer.mergeAll( - Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - Layer.succeed( - MobileStorage.MobileStorage, - MobileStorage.MobileStorage.of({ - loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessRegistrationRecord: Effect.succeed(null), - saveAgentAwarenessRegistrationRecord: () => Effect.void, - clearAgentAwarenessRegistrationRecord: Effect.void, - loadRecentThreadShortcuts: Effect.succeed([]), - saveRecentThreadShortcuts: () => Effect.void, - }), - ), -); - -describe("liveActivityPreferences", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.effect("restores relay preferences when an environment update fails", () => { - vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => - Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => true, - }), - ); - - expect(exit._tag).toBe("Failure"); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { - liveActivitiesEnabled: false, - }); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: false, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("only re-links environments with relay access when enabling updates", () => { - const readOnlyConnection: SavedRemoteConnection = { - ...connection, - environmentId: "read-only" as EnvironmentId, - }; - - return Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: false, - clerkToken: "clerk-token", - connections: [readOnlyConnection, connection], - canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, - }); - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({ - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("checks current relay access after updating the device registration", () => { - let canConfigureEnvironment = true; - vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() => - Effect.sync(() => { - canConfigureEnvironment = false; - }), - ); - - return Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => canConfigureEnvironment, - }); - - expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("enables updates when a host refuses an environment link proof", () => { - vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => - Effect.fail( - new CloudEnvironmentLinkError({ - message: "Could not obtain environment link proof: Invalid managed endpoint origin.", - }), - ), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: false, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => true, - }), - ); - - expect(exit).toStrictEqual(Exit.void); - expect(vi.mocked(updateAgentAwarenessRegistrationPreferences).mock.lastCall).toEqual([ - { liveActivitiesEnabled: true }, - ]); - }).pipe(Effect.provide(layerTest)); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts deleted file mode 100644 index 826edaf839b91..0000000000000 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { EnvironmentId } from "@t3tools/contracts"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; - -export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( - function* (input: { - readonly enabled: boolean; - readonly previousEnabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; - readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; - }) { - const linkedConnections = input.connections.filter( - (connection) => connection.bearerToken !== null, - ); - - const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( - connection: SavedRemoteConnection, - enabled: boolean, - clerkToken: string, - ) { - if (!input.canConfigureEnvironment(connection.environmentId)) return; - - yield* linkEnvironmentToCloudWithPreference({ - clerkToken, - connection, - liveActivitiesEnabled: enabled, - }); - }); - - const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { - yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: enabled, - }); - - const clerkToken = input.clerkToken; - if (!clerkToken) return; - - yield* Effect.forEach( - linkedConnections, - (connection) => updateEnvironmentPreference(connection, enabled, clerkToken), - { concurrency: "unbounded" }, - ); - }); - - const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { - yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: input.previousEnabled, - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not restore Live Activity device preference.", cause), - ), - ); - - const clerkToken = input.clerkToken; - if (!clerkToken) return; - - yield* Effect.forEach( - linkedConnections, - (connection) => - updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `Could not restore Live Activity preference for environment ${connection.environmentId}.`, - cause, - ), - ), - ), - { concurrency: "unbounded" }, - ); - }); - - yield* updateRelayPreference(input.enabled).pipe( - Effect.onError(() => restoreRelayPreference()), - ); - }, -); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..bfffea30d38f7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -656,6 +656,47 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + it.effect("enables Live Activity updates over a stored disabled preference", () => { + const requests: Array<{ readonly url: string; readonly body: 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", + }); + } + requests.push({ url: request.url, body: 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(); + requests.length = 0; + + const exit = yield* Effect.exit( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + + expect(exit).toStrictEqual(Exit.void); + expect(requests).toMatchObject([ + { + url: "https://relay.example.test/v1/mobile/devices", + body: { preferences: { liveActivitiesEnabled: 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({ diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts deleted file mode 100644 index 2f9618e0953cd..0000000000000 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { beforeEach, vi } from "vite-plus/test"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { EnvironmentId } from "@t3tools/contracts"; -import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; -import { HttpClient } from "effect/http"; - -import * as MobileStorage from "../../persistence/mobile-storage"; - -import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: { - relay: { - url: "https://relay.example.test", - }, - }, - }, - }, -})); - -vi.mock("expo-device", () => ({ - deviceType: 1, - DeviceType: { - UNKNOWN: 0, - PHONE: 1, - TABLET: 2, - DESKTOP: 3, - TV: 4, - }, - osVersion: "18.4.1", - modelName: "iPhone 15 Pro", -})); - -vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, -})); - -vi.mock("expo-secure-store", () => ({ - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), -})); - -const savedConnection = { - environmentId: EnvironmentId.make("env-1"), - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example.test/", - displayUrl: "https://desktop.example.test/", - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - bearerToken: "local-bearer", -}; - -const createProofMock = vi.fn( - (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => - Effect.succeed(`dpop:${input.method}:${input.url}`), -); -const layerTestDpopSigner = Layer.succeed( - ManagedRelay.ManagedRelayDpopSigner, - ManagedRelay.ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("client-proof-key-thumbprint"), - createProof: (input) => createProofMock(input), - }), -); - -function layerCloudClient() { - const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)); - return Layer.mergeAll( - layerHttpClient, - Layer.succeed( - MobileStorage.MobileStorage, - MobileStorage.MobileStorage.of({ - loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessRegistrationRecord: Effect.succeed(null), - saveAgentAwarenessRegistrationRecord: () => Effect.void, - clearAgentAwarenessRegistrationRecord: Effect.void, - loadRecentThreadShortcuts: Effect.succeed([]), - saveRecentThreadShortcuts: () => Effect.void, - }), - ), - ManagedRelay.layer({ - relayUrl: "https://relay.example.test", - clientId: RelayMobileClientId, - }).pipe(Layer.provideMerge(layerTestDpopSigner), Layer.provide(layerHttpClient)), - ); -} - -const withCloudServices = ( - effect: Effect.Effect< - A, - E, - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | ManagedRelay.ManagedRelayDpopSigner - | MobileStorage.MobileStorage - >, -) => effect.pipe(Effect.provide(layerCloudClient())); - -function validLinkProof() { - return "signed-environment-link-jwt"; -} - -function validLinkResponse(environmentId = "env-1") { - return { - ok: true, - environmentId, - endpoint: { - httpBaseUrl: "https://managed.example.test/", - wsBaseUrl: "wss://managed.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - }, - relayIssuer: "https://relay.example.test", - cloudUserId: "user_123", - environmentCredential: "environment-credential", - cloudMintPublicKey: "cloud-mint-public-key", - }; -} - -function validLinkChallengeResponse() { - return { - challenge: "link-challenge", - expiresAt: "2026-05-25T00:05:00.000Z", - }; -} - -function requestBodyText(body: BodyInit | null | undefined): string { - return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); -} - -describe("mobile cloud link environment client", () => { - beforeEach(() => { - vi.restoreAllMocks(); - createProofMock.mockClear(); - }); - - it.effect( - "rejects relay link credentials for a different environment before persisting relay config", - () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - return Promise.resolve(Response.json(validLinkResponse("env-other"))); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); - }), - ); - - it.effect("rejects relay link credentials for a different managed endpoint provider", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - return Promise.resolve( - Response.json({ - ...validLinkResponse(), - endpoint: { - ...validLinkResponse().endpoint, - providerKind: "manual", - }, - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); - }), - ); - - it.effect("preserves disabled Live Activity preferences when linking an environment", () => - Effect.gen(function* () { - const bodies: Array = []; - const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { - if (init?.body) { - bodies.push(JSON.parse(requestBodyText(init.body))); - } - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - if (String(url).endsWith("/v1/client/environment-links")) { - return Promise.resolve(Response.json(validLinkResponse())); - } - return Promise.resolve( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: false, - }), - ); - - expect(bodies[1]).toMatchObject({ - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: 443, - }, - }); - expect(bodies[2]).toMatchObject({ - deviceId: "device-1", - notificationsEnabled: true, - liveActivitiesEnabled: false, - managedTunnelsEnabled: true, - }); - expect(bodies[3]).toMatchObject({ - cloudUserId: "user_123", - environmentCredential: "environment-credential", - }); - }), - ); -}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts deleted file mode 100644 index 3aef69252932f..0000000000000 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ /dev/null @@ -1,239 +0,0 @@ -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; -import { - EnvironmentCloudEndpointUnavailableError, - EnvironmentHttpBadRequestError, - EnvironmentHttpConflictError, - EnvironmentHttpForbiddenError, - EnvironmentHttpInternalServerError, - EnvironmentHttpUnauthorizedError, -} from "@t3tools/contracts"; -import { - type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, - type RelayManagedEndpointProviderKind, -} from "@t3tools/contracts/relay"; -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobileStorage from "../../persistence/mobile-storage"; -import { resolveCloudPublicConfig } from "./publicConfig"; - -function readRelayUrl(): string | null { - return resolveCloudPublicConfig().relay.url; -} - -export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ - readonly message: string; - readonly cause?: unknown; - readonly traceId?: string; -}> {} - -const isEnvironmentCloudApiError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpForbiddenError, - EnvironmentHttpConflictError, - EnvironmentHttpInternalServerError, - EnvironmentCloudEndpointUnavailableError, - ]), -); - -const MANAGED_ENDPOINT_PROVIDER_KIND = - "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; - -function cloudEnvironmentLinkError(message: string) { - return (cause: unknown) => { - const environmentError = findEnvironmentCloudApiError(cause); - const traceId = findErrorTraceId(cause); - const detail = environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause); - return new CloudEnvironmentLinkError({ - message: detail, - cause, - ...(traceId === null ? {} : { traceId }), - }); - }; -} - -function isDevRuntime(): boolean { - return typeof __DEV__ !== "undefined" && __DEV__; -} - -function causeMessage(cause: unknown): string | null { - if (cause instanceof Error && cause.message) { - return cause.message; - } - if (typeof cause === "object" && cause !== null) { - const record = cause as { readonly message?: unknown; readonly cause?: unknown }; - if (typeof record.message === "string" && record.message.length > 0) { - const nested = causeMessage(record.cause); - return nested ? `${record.message}: ${nested}` : record.message; - } - } - return null; -} - -function withDevCause(message: string, cause: unknown): string { - if (!isDevRuntime()) { - return message; - } - const detail = causeMessage(cause); - return detail ? `${message} (${detail})` : message; -} - -function decodedRelayClientError(message: string) { - return (cause: ManagedRelay.ManagedRelayClientError) => { - const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined; - const traceId = failed?.traceId; - const detail = failed ? relayRequestFailureDetail(failed) : null; - return new CloudEnvironmentLinkError({ - message: detail ? `${message}: ${detail}` : message, - cause, - ...(traceId ? { traceId } : {}), - }); - }; -} - -function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { - if (isEnvironmentCloudApiError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; -} - -function requireRelayUrl(): Effect.Effect { - const relayUrl = readRelayUrl(); - return relayUrl - ? Effect.succeed(relayUrl) - : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); -} - -function endpointOrigin(httpBaseUrl: string) { - const url = new URL(httpBaseUrl); - return { - localHttpHost: "127.0.0.1", - localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; -} - -function ensureLinkedEnvironmentMatches(input: { - readonly expectedEnvironmentId: string; - readonly expectedProviderKind: RelayManagedEndpointProviderKind; - readonly link: RelayEnvironmentLinkResponseType; -}): Effect.Effect { - if (input.link.environmentId !== input.expectedEnvironmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if (input.link.endpoint.providerKind !== input.expectedProviderKind) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint provider.", - }); - } - return Effect.void; -} - -interface LinkEnvironmentToCloudInput { - readonly connection: SavedRemoteConnection; - readonly clerkToken: string; -} - -type LinkEnvironmentToCloudRequirements = - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | MobileStorage.MobileStorage; - -export const linkEnvironmentToCloudWithPreference = Effect.fn( - "linkEnvironment.linkEnvironmentToCloudWithPreference", -)(function* ( - input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, -): Effect.fn.Return { - if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can be linked to the cloud.", - }); - } - const localBearerToken = input.connection.bearerToken; - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const storage = yield* MobileStorage.MobileStorage; - const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), - ); - const liveActivitiesEnabled = input.liveActivitiesEnabled; - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), - ), - ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - const proof = yield* environmentClient.connect - .linkProof({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - challenge: challenge.challenge, - relayIssuer: relayUrl, - endpoint: { - httpBaseUrl: input.connection.httpBaseUrl, - wsBaseUrl: input.connection.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.connection.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - deviceId, - proof, - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.connection.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, - }); - - yield* environmentClient.connect - .relayConfig({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), - ); -}); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3b34f6fb2d664..919732e1a7650 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -60,10 +60,3 @@ export function useManagedRelayEnvironments() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 4aa5cc4c70be8..3c9ba909b96cb 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -1,6 +1,4 @@ import * as Dialog from "../../components/ui/Dialog"; -import { AuthRelayWriteScope } from "@t3tools/contracts"; -import { readEnvironmentScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAuth } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; @@ -25,19 +23,17 @@ import { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings, } from "../agent-awareness/androidNotifications"; -import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, + updateAgentAwarenessRegistrationPreferences, } from "../agent-awareness/remoteRegistration"; -import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -45,7 +41,7 @@ import { SettingsScreen } from "./components/SettingsScreen"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; -type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "updating"; const notificationStatusAtom = makePermissionStatusAtom({ label: "mobile:notification-permission", @@ -118,8 +114,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { savedConnectionsById } = useSavedRemoteConnections(); + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const notificationStatus = useAtomValue(notificationStatusAtom); const setNotificationStatus = useAtomSet(notificationStatusAtom); // A write in progress or a failed one shows its own status until the auth or @@ -140,16 +135,10 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityOverride({ status, basis: liveActivityBasis }); const liveActivityWriteInFlight = useRef(false); const deviceRegistered = useDeviceRegistered(); - const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.liveActivitiesEnabled !== false - : true; const canClearLiveActivitiesPreference = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.liveActivitiesEnabled !== false; - const connections = Object.values(savedConnectionsById); - const environmentCount = connections.length; - const requestNotifications = async () => { const result = await settleAsyncResult(() => runtime.runPromiseExit( @@ -219,13 +208,13 @@ function ConfiguredSettingsNotificationsRouteScreen() { ); }; - const linkEnvironments = async () => { + const enableLiveActivities = async () => { if (!isSignedIn) { promptSignIn(); return; } - setLiveActivityStatus("linking"); + setLiveActivityStatus("updating"); if (Platform.OS === "android") { const permission = await settleAsyncResult(() => runtime.runPromiseExit(requestAgentNotificationPermission), @@ -253,32 +242,9 @@ function ConfiguredSettingsNotificationsRouteScreen() { } setNotificationStatus("enabled"); } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - setLiveActivityStatus("disabled"); - const error = squashAtomCommandFailure(tokenResult); - Dialog.alert( - Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable agent activity updates.", - ); - return; - } - if (!tokenResult.value) { - promptSignIn(); - setLiveActivityStatus("signed-out"); - return; - } - const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: (environmentId) => - readEnvironmentScope(environmentId, AuthRelayWriteScope), - enabled: true, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: tokenResult.value, - connections, - }), + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), ), ); if (updateResult._tag === "Failure") { @@ -296,15 +262,11 @@ function ConfiguredSettingsNotificationsRouteScreen() { } savePreferences({ liveActivitiesEnabled: true }); - refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); - // Environment linking can precede device registration; only registered devices can receive Live Activities. if (getAgentAwarenessRegistrationStatus() === "registered") { Dialog.alert( Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", - environmentCount > 0 - ? "Agent activity updates are enabled for environments this connection can configure." - : "Agent activity updates are enabled. Add an environment to start receiving updates.", + "Agent activity updates are enabled for this device.", ); } else { Dialog.alert( @@ -335,29 +297,9 @@ function ConfiguredSettingsNotificationsRouteScreen() { }; async function disableLiveActivities() { - let token: string | null = null; - if (isSignedIn) { - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - setLiveActivityStatus("enabled"); - reportAtomCommandResult(tokenResult, { - label: "live activity disable token lookup", - }); - return; - } - token = tokenResult.value; - } - const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: (environmentId) => - readEnvironmentScope(environmentId, AuthRelayWriteScope), - enabled: false, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: token, - connections, - }), + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: false }), ), ); if (updateResult._tag === "Failure") { @@ -368,7 +310,6 @@ function ConfiguredSettingsNotificationsRouteScreen() { return; } savePreferences({ liveActivitiesEnabled: false }); - refreshManagedRelayEnvironments(); setLiveActivityStatus("disabled"); } @@ -377,7 +318,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { if (liveActivityWriteInFlight.current) return; if (!enabled) { liveActivityWriteInFlight.current = true; - setLiveActivityStatus("linking"); + setLiveActivityStatus("updating"); await disableLiveActivities(); liveActivityWriteInFlight.current = false; return; @@ -389,7 +330,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } liveActivityWriteInFlight.current = true; - await linkEnvironments(); + await enableLiveActivities(); liveActivityWriteInFlight.current = false; }; @@ -426,7 +367,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { !agentAwarenessPushAvailable || !isLoaded || liveActivityStatus === "checking" || - liveActivityStatus === "linking" + liveActivityStatus === "updating" } icon="bolt.circle" label={ @@ -439,7 +380,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { subtitle={agentAwarenessSubtitle} value={ agentAwarenessPushAvailable && - (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && + (liveActivityStatus === "enabled" || liveActivityStatus === "updating") && deviceRegistered } onValueChange={(enabled) => void handleLiveActivitiesChange(enabled)} From 5ea00e00940056f529cfb96fb8a12bd57522922e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:11:47 +0000 Subject: [PATCH 3/5] fix(mobile): restore Android activity config when a preference update is rejected Review round 1 found that the toggle's direct device write left the native Android preference applied after the relay rejected it. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 27 +++++++++---------- .../agent-awareness/remoteRegistration.ts | 26 +++++++++++++----- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index bfffea30d38f7..e628e94f2e76e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -656,8 +656,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); - it.effect("enables Live Activity updates over a stored disabled preference", () => { - const requests: Array<{ readonly url: string; readonly body: unknown }> = []; + it.effect("keeps Android activity off when the relay rejects turning it on", () => { + 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.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init); if (request.url.endsWith("/v1/client/dpop-token")) { @@ -669,28 +674,22 @@ describe("makeRelayDeviceRegistrationRequest", () => { scope: "mobile:registration", }); } - requests.push({ url: request.url, body: await request.json() }); - return Response.json({ ok: true }); + return new Response("Service unavailable", { status: 503 }); }); Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); return Effect.gen(function* () { - yield* runBackgroundOperations(); - requests.length = 0; - const exit = yield* Effect.exit( updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), ); - expect(exit).toStrictEqual(Exit.void); - expect(requests).toMatchObject([ - { - url: "https://relay.example.test/v1/mobile/devices", - body: { preferences: { liveActivitiesEnabled: true } }, - }, + expect(Exit.isFailure(exit)).toBe(true); + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + false, ]); - expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); }).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 fc24d180a3a04..9361923396666 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -651,13 +651,16 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* input.preferencesOverride, ); if (expectedGeneration !== deviceRegistrationGeneration) return; - if (relayTokenProvider && relayTokenProviderIdentity) { - configureAndroidAgentNotifications( - deviceId, - relayTokenProviderIdentity, - preferences.liveActivitiesEnabled !== false, - ); - } + const configureAndroid = (registered: Preferences) => { + if (relayTokenProvider && relayTokenProviderIdentity) { + configureAndroidAgentNotifications( + deviceId, + relayTokenProviderIdentity, + registered.liveActivitiesEnabled !== false, + ); + } + }; + configureAndroid(preferences); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); const bundleId = Platform.OS === "android" @@ -682,6 +685,15 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* preferences, }), expectedGeneration, + ).pipe( + // A rejected preference change must not stay applied natively while the relay keeps the old one. + Effect.onError(() => + Effect.sync(() => { + if (expectedGeneration === deviceRegistrationGeneration) { + configureAndroid(storedPreferences); + } + }), + ), ); }); From 798f1d7c23cee99b9fa4ac07cff62744a5a74176 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:30:59 +0000 Subject: [PATCH 4/5] fix(mobile): a failed older registration no longer undoes a newer activity toggle Review round 2 found that the Android restore could put an older registration's preference back over a newer successful write. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 59 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 10 +++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e628e94f2e76e..197512334c7a0 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -696,6 +696,65 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + it.effect("keeps a newer Android activity toggle when an older registration fails", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + let olderRequestArrived!: () => void; + const olderArrived = new Promise((resolve) => { + olderRequestArrived = resolve; + }); + let failOlderRequest!: () => void; + const olderFails = new Promise((resolve) => { + failOlderRequest = resolve; + }); + let deviceRequests = 0; + 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", + }); + } + deviceRequests += 1; + if (deviceRequests === 1) { + olderRequestArrived(); + await olderFails; + return new Response("Service unavailable", { status: 503 }); + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + + return Effect.gen(function* () { + const older = yield* Effect.forkChild( + Effect.exit(updateAgentAwarenessRegistrationPreferences({})), + ); + yield* Effect.promise(() => olderArrived); + + yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }); + failOlderRequest(); + expect(Exit.isFailure(yield* Fiber.join(older))).toBe(true); + + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + 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 9361923396666..e864dee512ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -115,6 +115,7 @@ let activeLiveActivityRegistrationRetry: ReturnType | null = let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; let deviceRegistrationGeneration = 0; +let androidConfigurationWrite = 0; let activeDeviceRegistration: { readonly input: DeviceRegistrationInput; operation: Promise; @@ -661,6 +662,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* } }; configureAndroid(preferences); + const configurationWrite = ++androidConfigurationWrite; const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); const bundleId = Platform.OS === "android" @@ -686,10 +688,14 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* }), expectedGeneration, ).pipe( - // A rejected preference change must not stay applied natively while the relay keeps the old one. + // A rejected preference change must not stay applied natively while the relay keeps the old + // one, unless a newer registration has configured the device since. Effect.onError(() => Effect.sync(() => { - if (expectedGeneration === deviceRegistrationGeneration) { + if ( + expectedGeneration === deviceRegistrationGeneration && + configurationWrite === androidConfigurationWrite + ) { configureAndroid(storedPreferences); } }), From aa315fd919d84a3988903d6296a5bc19b02d175d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:51:33 +0000 Subject: [PATCH 5/5] fix(mobile): restore Android activity config when the permission lookup fails Review round 3 found the native preference stayed applied when reading notification permission failed before the relay write. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 23 ++++++++++++++ .../agent-awareness/remoteRegistration.ts | 31 ++++++++++--------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 197512334c7a0..423e7dabf0c6b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -696,6 +696,29 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + it.effect("keeps Android activity off when reading notification permission fails", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce( + new Error("permission lookup failed"), + ); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + false, + ]); + }).pipe(Effect.provide(layerRelayTest)); + }); + it.effect("keeps a newer Android activity toggle when an older registration fails", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.spyOn(Platform, "Version", "get").mockReturnValue(36); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index e864dee512ca3..5529bb0e661b1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -663,7 +663,21 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* }; configureAndroid(preferences); const configurationWrite = ++androidConfigurationWrite; - const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + // A failed registration must not leave its preference applied natively while the relay keeps + // the old one, unless a newer registration has configured the device since. + const restoreAndroidOnError = Effect.onError(() => + Effect.sync(() => { + if ( + expectedGeneration === deviceRegistrationGeneration && + configurationWrite === androidConfigurationWrite + ) { + configureAndroid(storedPreferences); + } + }), + ); + const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken).pipe( + restoreAndroidOnError, + ); const bundleId = Platform.OS === "android" ? Constants.expoConfig?.android?.package?.trim() @@ -687,20 +701,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* preferences, }), expectedGeneration, - ).pipe( - // A rejected preference change must not stay applied natively while the relay keeps the old - // one, unless a newer registration has configured the device since. - Effect.onError(() => - Effect.sync(() => { - if ( - expectedGeneration === deviceRegistrationGeneration && - configurationWrite === androidConfigurationWrite - ) { - configureAndroid(storedPreferences); - } - }), - ), - ); + ).pipe(restoreAndroidOnError); }); function registerDeviceForCurrentUser(): Effect.Effect<