Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -391,26 +391,75 @@ describe("makeRelayDeviceRegistrationRequest", () => {
).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 });
});

it.effect("registers at most one listener while a Live Activity push token is pending", () => {
it.effect("registers one push-token listener per Live Activity across wrappers", () => {
registerAgentAwarenessConnection(savedConnection());
const addPushTokenListener = vi.fn();
const activity = {
// getInstances() returns a new wrapper for the same native activity each call.
const wrapper = () => ({
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve(null)),
addPushTokenListener,
};
});

return Effect.gen(function* () {
expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
expect(yield* registerLiveActivityPushToken({ activity: wrapper() as never })).toBe(false);
expect(yield* registerLiveActivityPushToken({ activity: wrapper() as never })).toBe(false);

expect(activity.getPushToken).toHaveBeenCalledTimes(2);
expect(addPushTokenListener).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(layerRelayTest));
});

it.effect("registers a Live Activity push token that rotates after registration", () => {
const registeredTokens: Array<string> = [];
vi.stubGlobal("fetch", async (request: RequestInfo | URL, init?: RequestInit) => {
const url = request instanceof Request ? request.url : String(request);
if (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 (url.endsWith("/v1/mobile/live-activities")) {
const body = request instanceof Request ? await request.text() : String(init?.body);
registeredTokens.push(
(JSON.parse(body) as { readonly activityPushToken: string }).activityPushToken,
);
}
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
let tokenListener: ((event: { readonly pushToken: string }) => void) | undefined;
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn((listener: typeof tokenListener) => {
tokenListener = listener;
return { remove: vi.fn() };
}),
};

return Effect.gen(function* () {
yield* runBackgroundOperations();
expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(true);

tokenListener?.({ pushToken: "rotated-activity-token" });
yield* runBackgroundOperations();

expect(registeredTokens).toEqual(["activity-token", "rotated-activity-token"]);
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
});

it.effect("preserves Live Activity push-token lookup failures", () => {
const cause = new Error("native token lookup failed");
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.reject(cause)),
addPushTokenListener: vi.fn(),
};
Expand All @@ -435,6 +484,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
() => {
registerAgentAwarenessConnection(savedConnection());
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
};
Expand All @@ -449,6 +499,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
"registers APNS-started Live Activities for relay updates without mutating them locally",
() => {
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
start: vi.fn(),
Expand All @@ -473,6 +524,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
"re-registers active Live Activity tokens when the app returns to the foreground",
() => {
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
};
Expand Down Expand Up @@ -502,6 +554,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
const end = vi.fn(() => Promise.resolve());
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
end,
Expand Down Expand Up @@ -613,6 +666,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
it("releases the provider without ending activities or clearing the registration", () => {
const end = vi.fn(() => Promise.resolve());
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
end,
Expand Down Expand Up @@ -708,6 +762,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
},
};
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
};
Expand Down
56 changes: 26 additions & 30 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ export class AgentAwarenessOperationError extends Schema.TaggedError<AgentAwaren
}

const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
// Keyed by ActivityKit id: getAgentLiveActivities() wraps the same native
// activity in a new object on every call.
const activityPushTokenListeners = new Set<string>();
// But the registration is not a pure no-op: the relay replays the current
// aggregate to this device on every accepted registration, and that replay is
// the foreground reconciliation that repairs drifted or orphaned activities.
Expand Down Expand Up @@ -893,6 +895,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
androidDeviceReplayedAt = null;
registrationStatusListeners.clear();
registeredActivityPushTokens.clear();
activityPushTokenListeners.clear();
}

export function unregisterAgentAwarenessDeviceForCurrentUser(
Expand Down Expand Up @@ -928,6 +931,7 @@ export function registerLiveActivityPushToken(input: {
return false;
}

observeLiveActivityPushTokens(input.activity);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const activityPushToken = yield* Effect.tryPromise({
try: () => input.activity.getPushToken(),
catch: (cause) =>
Expand All @@ -937,35 +941,8 @@ export function registerLiveActivityPushToken(input: {
}),
});
if (!activityPushToken) {
if (activityPushTokenListeners.has(input.activity)) {
logRegistrationDebug(
"live activity push token not available yet; token listener already registered",
{
connectionCount: environmentConnections.size,
},
);
return false;
}

logRegistrationDebug(
"live activity push token not available yet; listening for token event",
{
connectionCount: environmentConnections.size,
},
);
activityPushTokenListeners.add(input.activity);
input.activity.addPushTokenListener((event) => {
if (event.pushToken) {
logRegistrationDebug("live activity push token event received", {
tokenSuffix: event.pushToken.slice(-8),
});
runRegistrationInBackground(
registerLiveActivityPushTokenValue({
activityPushToken: event.pushToken,
}),
"live activity token listener registration failed",
);
}
logRegistrationDebug("live activity push token not available yet; waiting for token event", {
connectionCount: environmentConnections.size,
});
return false;
}
Expand All @@ -976,6 +953,25 @@ export function registerLiveActivityPushToken(input: {
});
}

// iOS can replace an activity's push token while it runs, not only deliver a
// late first one. The relay keeps pushing to the old token until APNs rejects
// it, so every activity needs a listener that registers each new token.
function observeLiveActivityPushTokens(activity: LiveActivity<AgentActivityProps>): void {
const activityId = activity.getId();
if (activityPushTokenListeners.has(activityId)) return;
activityPushTokenListeners.add(activityId);
activity.addPushTokenListener((event) => {
if (!event.pushToken) return;
logRegistrationDebug("live activity push token event received", {
tokenSuffix: event.pushToken.slice(-8),
});
runRegistrationInBackground(
registerLiveActivityPushTokenValue({ activityPushToken: event.pushToken }),
"live activity token listener registration failed",
);
});
}

function registerLiveActivityPushTokenValue(input: {
readonly activityPushToken: string;
}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
Expand Down
Loading