Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ import type { Preferences } from "../../persistence/mobile-preferences";
import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
import {
__resetAgentAwarenessRemoteRegistrationForTest,
agentAwarenessNotificationsReady,
getAgentAwarenessRegistrationStatus,
refreshActiveLiveActivityRemoteRegistration,
refreshAgentAwarenessRegistration,
registerLiveActivityPushToken,
releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
subscribeAgentAwarenessRegistrationStatus,
updateAgentAwarenessRegistrationPreferences,
type AgentAwarenessRegistrationError,
} from "./remoteRegistration";
Expand Down Expand Up @@ -709,6 +711,115 @@ describe("makeRelayDeviceRegistrationRequest", () => {
);
},
);

const stubAndroidDeviceRelay = () => {
vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
const registrations: Array<{
readonly preferences: { readonly notificationsEnabled: boolean };
}> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "relay-dpop-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
if (request.url.endsWith("/v1/mobile/devices")) {
registrations.push(await request.json());
}
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
return registrations;
};

it.effect("does not report notifications ready when its push token lookup fails", () => {
const registrations = stubAndroidDeviceRelay();
vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue(
new Error("Default FirebaseApp is not initialized"),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");

return Effect.gen(function* () {
yield* runBackgroundOperations();

// Live Activities register with their own tokens, so the device stays registered.
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
expect(agentAwarenessNotificationsReady()).toBe(false);
expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]);
expect(registrations[0]).not.toHaveProperty("pushToken");

const onStatusChange = vi.fn();
subscribeAgentAwarenessRegistrationStatus(onStatusChange);
const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
listener({ type: "android", data: "fcm-token" } as never);
yield* runBackgroundOperations();

expect(agentAwarenessNotificationsReady()).toBe(true);
expect(onStatusChange).toHaveBeenCalled();
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
});

it.effect("keeps notifications enabled when a refresh cannot read the push token", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the cached-token failure setup in the preference-change test. Could you fold the refresh and its assertions into that case before the preference update?

Posted via Macroscope — Test Signal

const registrations = stubAndroidDeviceRelay();
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
type: "android",
data: "fcm-token",
});
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");

return Effect.gen(function* () {
yield* runBackgroundOperations();
expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]);
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");

vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce(
new Error("SERVICE_NOT_AVAILABLE"),
);
yield* refreshAgentAwarenessRegistration();

expect(registrations).toMatchObject([{ preferences: { notificationsEnabled: true } }]);
expect(agentAwarenessNotificationsReady()).toBe(true);

// A lookup that started before a rotation must not replace the rotated token.
let resolveStaleLookup: (token: { type: "android"; data: string }) => void = () => {};
let markLookupStarted: () => void = () => {};
const lookupStarted = new Promise<void>((resolve) => {
markLookupStarted = resolve;
});
vi.mocked(Notifications.getDevicePushTokenAsync).mockImplementationOnce(() => {
markLookupStarted();
return new Promise((resolve) => {
resolveStaleLookup = resolve;
});
});
const staleRefresh = yield* Effect.forkChild(refreshAgentAwarenessRegistration());
yield* Effect.promise(() => lookupStarted);
const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
listener({ type: "android", data: "rotated-fcm-token" } as never);
yield* runBackgroundOperations();
resolveStaleLookup({ type: "android", data: "fcm-token" });
yield* Fiber.join(staleRefresh);
vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValueOnce(
new Error("SERVICE_NOT_AVAILABLE"),
);
yield* refreshAgentAwarenessRegistration();

expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated-fcm-token" });
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
});

it.effect.each(["blocked", "unavailable"] as const)(
"keeps Android push delivery registered when the alert channel is %s",
(channelState) => {
Expand Down
37 changes: 31 additions & 6 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,25 @@ const registeredActivityPushTokens = new Map<string, number>();
// repaint, so a slow older response never overwrites newer rows.
let widgetSnapshotReads = 0;
let androidDeviceReplayedAt: number | null = null;
let lastNativePushToken: string | null = null;
let registeredWithPushToken = false;
let nativePushTokenRotations = 0;
let pushTokenSubscription: { remove: () => void } | null = null;
let appStateSubscription: { remove: () => void } | null = null;

export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed";
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();
}
Expand All @@ -116,6 +123,12 @@ export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistratio
return registrationStatus;
}

// Live Activities use their own push tokens, so device registration alone does not mean
// the relay can deliver notifications.
export function agentAwarenessNotificationsReady(): boolean {
return registrationStatus === "registered" && registeredWithPushToken;
}

export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void {
registrationStatusListeners.add(listener);
return () => {
Expand Down Expand Up @@ -291,12 +304,15 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke
)
: true);
if (observedPushToken) {
lastNativePushToken = observedPushToken;
nativePushTokenRotations++;
return {
notificationsEnabled: alertsEnabled,
pushToken: observedPushToken,
notificationPermissionGranted: permissions.granted,
};
}
const rotationsBeforeLookup = nativePushTokenRotations;
const token = yield* Effect.tryPromise({
try: () => Notifications.getDevicePushTokenAsync(),
catch: (cause) =>
Expand All @@ -312,12 +328,18 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke
),
Effect.orElseSucceed(() => null),
);
const pushToken =
const lookedUpPushToken =
token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0
? token.data.trim()
: null;
// A rotation observed while this lookup ran is newer than what the lookup returned.
if (lookedUpPushToken && rotationsBeforeLookup === nativePushTokenRotations) {
lastNativePushToken = lookedUpPushToken;
}
const pushToken = lastNativePushToken;
// A missing token must not disable notifications the relay accepted.
return {
notificationsEnabled: alertsEnabled && pushToken !== null,
notificationsEnabled: alertsEnabled,
pushToken,
notificationPermissionGranted: permissions.granted,
};
Expand Down Expand Up @@ -405,7 +427,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith
persisted.signature === signature &&
!needsAndroidReplay
) {
setRegistrationStatus("registered");
setRegistrationStatus("registered", Boolean(payload.pushToken));
return;
}

Expand All @@ -421,7 +443,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith
return;
}
if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis;
setRegistrationStatus("registered");
setRegistrationStatus("registered", Boolean(payload.pushToken));
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({
identity,
Expand Down Expand Up @@ -855,7 +877,10 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
registeredWithPushToken = false;
androidDeviceReplayedAt = null;
lastNativePushToken = null;
nativePushTokenRotations = 0;
registrationStatusListeners.clear();
registeredActivityPushTokens.clear();
activityPushTokenLocks.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
import {
agentAwarenessNotificationsReady,
getAgentAwarenessRegistrationStatus,
refreshAgentAwarenessRegistration,
subscribeAgentAwarenessRegistrationStatus,
Expand Down Expand Up @@ -108,6 +109,14 @@ function useDeviceRegistered(): boolean {
return status === "registered";
}

function useNotificationsReady(): boolean {
return useSyncExternalStore(
subscribeAgentAwarenessRegistrationStatus,
agentAwarenessNotificationsReady,
() => false,
);
}

export function SettingsNotificationsRouteScreen() {
if (!hasCloudPublicConfig()) {
return (
Expand Down Expand Up @@ -160,6 +169,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {
setLiveActivityOverride({ status, basis: liveActivityBasis });
const liveActivityWriteInFlight = useRef(false);
const deviceRegistered = useDeviceRegistered();
const notificationsReady = useNotificationsReady();
const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.liveActivitiesEnabled !== false
: true;
Expand Down Expand Up @@ -192,7 +202,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {
}
if (result.value.type === "granted") {
setNotificationStatus("enabled");
if (getAgentAwarenessRegistrationStatus() === "registered") {
if (agentAwarenessNotificationsReady()) {
Dialog.alert("Notifications enabled", "Agent notifications are enabled for this device.");
} else {
Dialog.alert(
Expand Down Expand Up @@ -434,7 +444,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {
}
subtitle={agentAwarenessSubtitle}
value={
agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered
agentAwarenessPushAvailable && notificationStatus === "enabled" && notificationsReady
}
onValueChange={handleDeviceNotificationsChange}
/>
Expand Down
47 changes: 47 additions & 0 deletions infra/relay/src/agentActivity/Devices.test.ts
Original file line number Diff line number Diff line change
@@ -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)))),
);
});
});
4 changes: 3 additions & 1 deletion infra/relay/src/agentActivity/Devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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,
Expand Down
Loading