1import { type LiveActivity } from "expo-widgets";
2import {
3 configureAndroidAgentNotifications,
4 clearAndroidAgentNotifications,
5} from "./androidNotifications";
6import Constants from "expo-constants";
7import * as Notifications from "expo-notifications";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10import { AppState, Platform } from "react-native";
11import type { EnvironmentId } from "@t3tools/contracts";
12import {
13 type RelayDeviceRegistrationRequest,
14 type RelayAgentActivitySnapshotResponse,
15 type RelayLiveActivityRegistrationRequest,
16} from "@t3tools/contracts/relay";
17import { findErrorTraceId } from "@t3tools/client-runtime/errors";
18import { ManagedRelay } from "@t3tools/client-runtime/relay";
19import {
20 isAtomCommandInterrupted,
21 settleAsyncResult,
22 squashAtomCommandFailure,
23} from "@t3tools/client-runtime/state/runtime";
24
25import type { SavedRemoteConnection } from "../../lib/connection";
26import { runtime } from "../../lib/runtime";
27import { appAtomRegistry } from "../../state/atom-registry";
28import { environmentServerConfigsAtom } from "../../state/server";
29import type { Preferences } from "../../persistence/mobile-preferences";
30import {
31 clearAgentAwarenessRegistrationRecord,
32 loadAgentAwarenessDeviceId,
33 loadAgentAwarenessRegistrationRecord,
34 loadOrCreateAgentAwarenessDeviceId,
35 loadPreferences,
36 saveAgentAwarenessRegistrationRecord,
37} from "../../persistence/imperative";
38import type { AgentActivityProps } from "../../widgets/AgentActivity";
39import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity";
40import { resolveCloudPublicConfig } from "../cloud/publicConfig";
41import { supportsAgentAwarenessPush } from "./capabilities";
42import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
43
44const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
45
46const AgentAwarenessOperation = Schema.Literals([
47 "read-notification-permissions",
48 "read-native-push-token",
49 "read-device-registration-relay-token",
50 "read-device-unregistration-relay-token",
51 "read-live-activity-registration-relay-token",
52 "load-device-registration-identifier",
53 "load-device-registration-preferences",
54 "load-device-unregistration-identifier",
55 "read-live-activity-push-token",
56 "load-live-activity-registration-identifier",
57 "list-active-live-activities",
58 "load-live-activity-prime-preferences",
59 "prime-live-activity",
60]);
61
62export class AgentAwarenessOperationError extends Schema.TaggedError<AgentAwarenessOperationError>()(
63 "AgentAwarenessOperationError",
64 {
65 operation: AgentAwarenessOperation,
66 cause: Schema.Defect(),
67 },
68) {
69 override get message(): string {
70 return `Agent awareness operation ${this.operation} failed.`;
71 }
72}
73
74const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
75const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
76// Activity tokens the relay recently accepted, by acceptance time. The refresh
77// runs on sign-in, every app foreground, and every environment-connection
78// update, which arrive in bursts and spammed identical registrations. But the
79// registration is not a pure no-op: the relay replays the current aggregate to
80// this device on every accepted registration, and that replay is the
81// foreground reconciliation that repairs drifted or orphaned activities. So
82// dedupe only within a short window — bursts collapse to one request, while a
83// foreground after real time away still triggers a replay. Cleared on
84// sign-out/identity change alongside the device registration state.
85const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
86// Locally started activities carry the same stale window the relay puts on
87// every push (STALE_AFTER_SECONDS in ApnsClient.ts), so a card whose relay
88// registration never lands still degrades instead of looking alive forever.
89const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000;
90const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS);
91const registeredActivityPushTokens = new Map<string, number>();
92let androidDeviceReplayedAt: number | null = null;
93let pushTokenSubscription: { remove: () => void } | null = null;
94let appStateSubscription: { remove: () => void } | null = null;
95
96// Whether the relay has actually accepted this device's registration. The
97// notification/Live Activity settings toggles must reflect this rather than
98// only local iOS permission or saved preferences: if the registration request
99// never succeeded, the device cannot receive anything, so the switches must
100// not read as enabled.
101export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed";
102let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
103const registrationStatusListeners = new Set<() => void>();
104
105function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
106 if (registrationStatus === next) {
107 return;
108 }
109 registrationStatus = next;
110 for (const listener of registrationStatusListeners) {
111 listener();
112 }
113}
114
115export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
116 return registrationStatus;
117}
118
119export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void {
120 registrationStatusListeners.add(listener);
121 return () => {
122 registrationStatusListeners.delete(listener);
123 };
124}
125let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
126let relayTokenProvider: (() => Promise<string | null>) | null = null;
127let relayTokenProviderIdentity: string | null = null;
128let deviceRegistrationGeneration = 0;
129let activeDeviceRegistration: {
130 readonly input: DeviceRegistrationInput;
131 operation: Promise<void>;
132} | null = null;
133let pendingDeviceRegistration: {
134 readonly input: DeviceRegistrationInput;
135 readonly context: string;
136} | null = null;
137
138interface DeviceRegistrationInput {
139 readonly observedPushToken?: string;
140}
141
142interface RegisterDeviceInput extends DeviceRegistrationInput {
143 readonly preferencesOverride?: Partial<Preferences>;
144}
145
146export function mergeAgentAwarenessRegistrationPreferences(
147 stored: Preferences,
148 override: Partial<Preferences> | undefined,
149): Preferences {
150 return { ...stored, ...override };
151}
152
153function readRelayConfig(): { readonly url: string } | null {
154 const relayUrl = resolveCloudPublicConfig().relay.url;
155 if (!relayUrl) {
156 logRegistrationDebug("relay registration skipped; relay config missing");
157 return null;
158 }
159
160 return { url: relayUrl };
161}
162
163function canRegisterRemoteLiveActivities(): boolean {
164 return Platform.OS === "ios";
165}
166
167function canRegisterPushNotifications(): boolean {
168 return Platform.OS === "ios" || Platform.OS === "android";
169}
170
171export function shouldRegisterAgentAwarenessDeviceForProvider(
172 previousIdentity: string | null,
173 identity: string | undefined,
174): boolean {
175 return identity === undefined || identity !== previousIdentity;
176}
177
178export function setAgentAwarenessRelayTokenProvider(
179 provider: (() => Promise<string | null>) | null,
180 identity?: string,
181): void {
182 const isExistingIdentity =
183 provider !== null &&
184 !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);
185 if (!isExistingIdentity) {
186 // Native configure compares the persisted account on cold start. An
187 // unset JS identity is a remount, not evidence of a different account.
188 if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) {
189 clearAndroidAgentNotifications();
190 }
191 androidDeviceReplayedAt = null;
192 deviceRegistrationGeneration++;
193 activeDeviceRegistration = null;
194 pendingDeviceRegistration = null;
195 registeredActivityPushTokens.clear();
196 }
197 relayTokenProvider = provider;
198 relayTokenProviderIdentity = provider ? (identity ?? null) : null;
199 if (!provider) {
200 clearAndroidAgentNotifications();
201 pushTokenSubscription?.remove();
202 pushTokenSubscription = null;
203 appStateSubscription?.remove();
204 appStateSubscription = null;
205 if (activeLiveActivityRegistrationRetry) {
206 clearTimeout(activeLiveActivityRegistrationRetry);
207 activeLiveActivityRegistrationRetry = null;
208 }
209 // Without a signed-in user the relay can no longer update or end these
210 // activities, so they would sit orphaned on the lock screen.
211 endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
212 setRegistrationStatus("unknown");
213 // Sign-out is the only thing that invalidates a stored registration, so the
214 // next sign-in re-registers.
215 void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => {
216 logRegistrationError("clear registration record on sign-out failed", error);
217 });
218 return;
219 }
220 ensurePushTokenListener();
221 ensureAppStateListener();
222 runRegistrationInBackground(
223 refreshActiveLiveActivityRemoteRegistration(),
224 "active live activity registration after cloud sign-in failed",
225 );
226 if (isExistingIdentity) {
227 // Same account re-activating (e.g. Clerk token refresh) normally needs no
228 // re-registration — but if the previous attempt never succeeded, this is
229 // the only trigger that will retry it before the next cold start.
230 if (registrationStatus !== "registered") {
231 enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed");
232 }
233 return;
234 }
235 enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
236}
237
238// Detach the provider and native listeners without the destructive sign-out
239// cleanup. For provider teardown while the user is still signed in (e.g. the
240// auth bridge unmounting/remounting), ending lock-screen activities and wiping
241// the persisted registration would be wrong — the relay still holds a valid
242// registration and the next mount reuses it.
243export function releaseAgentAwarenessRelayTokenProvider(): void {
244 relayTokenProvider = null;
245 relayTokenProviderIdentity = null;
246 deviceRegistrationGeneration++;
247 activeDeviceRegistration = null;
248 pendingDeviceRegistration = null;
249 androidDeviceReplayedAt = null;
250 pushTokenSubscription?.remove();
251 pushTokenSubscription = null;
252 appStateSubscription?.remove();
253 appStateSubscription = null;
254 if (activeLiveActivityRegistrationRetry) {
255 clearTimeout(activeLiveActivityRegistrationRetry);
256 activeLiveActivityRegistrationRetry = null;
257 }
258}
259
260function iosMajorVersion(): number {
261 const version = Platform.Version;
262 if (typeof version === "number") {
263 return Math.floor(version);
264 }
265 const major = Number.parseInt(version.split(".")[0] ?? "", 10);
266 return Number.isFinite(major) ? major : 18;
267}
268
269function nativePushTokenRegistration(observedPushToken?: string) {
270 return Effect.gen(function* () {
271 if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) {
272 return { notificationsEnabled: false, pushToken: null };
273 }
274 const permissions = yield* Effect.tryPromise({
275 try: () => Notifications.getPermissionsAsync(),
276 catch: (cause) =>
277 new AgentAwarenessOperationError({
278 operation: "read-notification-permissions",
279 cause,
280 }),
281 });
282 if (!permissions.granted) {
283 return { notificationsEnabled: false, pushToken: null };
284 }
285 if (observedPushToken) {
286 return { notificationsEnabled: true, pushToken: observedPushToken };
287 }
288 const token = yield* Effect.tryPromise({
289 try: () => Notifications.getDevicePushTokenAsync(),
290 catch: (cause) =>
291 new AgentAwarenessOperationError({
292 operation: "read-native-push-token",
293 cause,
294 }),
295 }).pipe(
296 Effect.tapError((error) =>
297 Effect.sync(() => {
298 logRegistrationError("native push token lookup failed", error);
299 }),
300 ),
301 Effect.orElseSucceed(() => null),
302 );
303 const pushToken =
304 token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0
305 ? token.data.trim()
306 : null;
307 return { notificationsEnabled: pushToken !== null, pushToken };
308 });
309}
310
311const relayToken = (
312 operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token",
313) =>
314 Effect.gen(function* () {
315 const provider = relayTokenProvider;
316 if (!provider) {
317 return null;
318 }
319 return yield* Effect.tryPromise({
320 try: provider,
321 catch: (cause) => new AgentAwarenessOperationError({ operation, cause }),
322 });
323 });
324
325// Stable fingerprint of everything the relay stores for this device. When it
326// matches the last accepted registration for the same account, re-registering
327// is a no-op, so a launch that changed nothing skips the request entirely.
328function registrationSignature(body: RelayDeviceRegistrationRequest): string {
329 return [
330 body.deviceId,
331 body.pushToken ?? "",
332 body.bundleId ?? "",
333 body.apsEnvironment ?? "",
334 body.appVersion ?? "",
335 body.label,
336 body.platform,
337 body.iosMajorVersion,
338 body.androidApiLevel,
339 body.preferences.notificationsEnabled,
340 body.preferences.liveActivitiesEnabled,
341 body.preferences.notifyOnApproval,
342 body.preferences.notifyOnInput,
343 body.preferences.notifyOnCompletion,
344 body.preferences.notifyOnFailure,
345 ].join("|");
346}
347
348function registerDeviceWithRelay(
349 body: RelayDeviceRegistrationRequest,
350 expectedGeneration: number,
351): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
352 return Effect.gen(function* () {
353 if (expectedGeneration !== deviceRegistrationGeneration) {
354 logRegistrationDebug("device registration cancelled before relay request", {
355 expectedGeneration,
356 currentGeneration: deviceRegistrationGeneration,
357 });
358 return;
359 }
360 const relayConfig = readRelayConfig();
361 if (!relayConfig) {
362 // Nothing is in flight and nothing can succeed until configuration
363 // appears; "pending" would otherwise stick forever.
364 setRegistrationStatus("unknown");
365 return;
366 }
367 const token = yield* relayToken("read-device-registration-relay-token");
368 if (expectedGeneration !== deviceRegistrationGeneration) {
369 logRegistrationDebug("device registration cancelled after auth lookup", {
370 expectedGeneration,
371 currentGeneration: deviceRegistrationGeneration,
372 });
373 return;
374 }
375 if (!token) {
376 logRegistrationDebug("relay device registration skipped; user is not signed in");
377 setRegistrationStatus("unknown");
378 return;
379 }
380
381 // Skip the request when this account already registered an identical
382 // payload; the relay upsert would be a no-op. The record is only cleared on
383 // sign-out, so a device stays registered across launches without re-hitting
384 // the relay every time the app opens.
385 const identity = relayTokenProviderIdentity ?? "";
386 const persisted = yield* Effect.tryPromise({
387 try: () => loadAgentAwarenessRegistrationRecord(),
388 catch: (cause) => cause,
389 }).pipe(Effect.orElseSucceed(() => null));
390 if (expectedGeneration !== deviceRegistrationGeneration) {
391 // Signed out while the record loaded — do not resurrect the cleared
392 // record or report the previous account's registration as current.
393 logRegistrationDebug("device registration cancelled after record lookup", {
394 expectedGeneration,
395 currentGeneration: deviceRegistrationGeneration,
396 });
397 return;
398 }
399 const payload = body;
400 // The relay URL participates so pointing the app at a different relay
401 // invalidates the record and re-registers there.
402 const signature = `${relayConfig.url}|${registrationSignature(payload)}`;
403 // Android registration also silently replays the current card. Collapse
404 // foreground bursts, but repair missed pushes on cold start or a return
405 // after time away, just like re-registering an iOS activity token.
406 const needsAndroidReplay =
407 body.platform === "android" &&
408 (androidDeviceReplayedAt === null ||
409 Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS);
410 if (
411 persisted &&
412 persisted.identity === identity &&
413 persisted.signature === signature &&
414 !needsAndroidReplay
415 ) {
416 setRegistrationStatus("registered");
417 logRegistrationDebug("relay device registration skipped; already registered for account", {
418 expectedGeneration,
419 });
420 return;
421 }
422
423 const client = yield* ManagedRelay.ManagedRelayClient;
424 logRegistrationDebug("relay device registration request started", {
425 expectedGeneration,
426 });
427 yield* client.registerDevice({
428 clerkToken: token,
429 payload,
430 });
431 if (expectedGeneration !== deviceRegistrationGeneration) {
432 // Signed out while the request was in flight: the sign-out path already
433 // reset the status and cleared the record for the next account, so a
434 // stale success must not overwrite either.
435 logRegistrationDebug("device registration completed after sign-out; result discarded", {
436 expectedGeneration,
437 currentGeneration: deviceRegistrationGeneration,
438 });
439 return;
440 }
441 if (body.platform === "android") androidDeviceReplayedAt = Date.now();
442 setRegistrationStatus("registered");
443 yield* Effect.promise(() =>
444 saveAgentAwarenessRegistrationRecord({
445 identity,
446 signature,
447 }).catch((error: unknown) => {
448 logRegistrationError("persist registration record failed", error);
449 }),
450 );
451 logRegistrationDebug("relay device registration request completed", {
452 expectedGeneration,
453 });
454 });
455}
456
457function unregisterDeviceWithRelay(input: {
458 readonly deviceId: string;
459 readonly tokenProvider: () => Promise<string | null>;
460}): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
461 return Effect.gen(function* () {
462 if (!readRelayConfig()) return;
463 const token = yield* Effect.tryPromise({
464 try: input.tokenProvider,
465 catch: (cause) =>
466 new AgentAwarenessOperationError({
467 operation: "read-device-unregistration-relay-token",
468 cause,
469 }),
470 });
471 if (!token) {
472 logRegistrationDebug("relay device unregistration skipped; user is not signed in");
473 return;
474 }
475
476 const client = yield* ManagedRelay.ManagedRelayClient;
477 yield* client.unregisterDevice({
478 clerkToken: token,
479 deviceId: input.deviceId,
480 });
481 });
482}
483
484// The environment descriptor advertises whether agent-activity publishes
485// currently leave that server (`capabilities.agentActivityPublishing`). Only
486// an explicit false skips the seed card: older servers omit the capability
487// but may still publish.
488function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean {
489 return (
490 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
491 .agentActivityPublishing !== false
492 );
493}
494
495// Arms the lock-screen card the moment the user starts agent work from this
496// phone, while the app is still foregrounded and the fresh activity's token
497// can be registered immediately. The seeded row is a best-effort placeholder;
498// the relay's registration replay repaints it with the authoritative
499// aggregate within seconds. No-ops when a card is already armed, and skips
500// environments that report publishing disabled — the seed would sit on
501// "Connecting" forever with no update ever arriving to repaint or end it.
502export function armAgentAwarenessLiveActivityForLocalWork(input: {
503 readonly environmentId: EnvironmentId;
504 readonly threadTitle: string;
505 readonly projectTitle: string;
506}): void {
507 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
508 return;
509 }
510 if (!environmentPublishesAgentActivity(input.environmentId)) {
511 logRegistrationDebug("live activity arming skipped; environment does not publish", {
512 environmentId: input.environmentId,
513 });
514 return;
515 }
516 void loadPreferences()
517 .catch(() => null)
518 .then((preferences) => {
519 if (preferences?.liveActivitiesEnabled === false) {
520 return;
521 }
522 armAgentAwarenessLiveActivityForLocalWorkNow(input);
523 });
524}
525
526function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
527 readonly threadTitle: string;
528 readonly projectTitle: string;
529}): void {
530 try {
531 if (getAgentLiveActivities().length > 0) {
532 return;
533 }
534 const nowIso = new Date(Date.now()).toISOString();
535 const activity = startAgentLiveActivity(
536 {
537 title: "The Knife",
538 subtitle: "Agent work in progress",
539 activeCount: 1,
540 updatedAt: nowIso,
541 activities: [
542 {
543 environmentId: "",
544 threadId: "",
545 projectTitle: input.projectTitle,
546 threadTitle: input.threadTitle,
547 modelTitle: "",
548 phase: "starting",
549 status: "Connecting",
550 updatedAt: nowIso,
551 deepLink: "/",
552 },
553 ],
554 },
555 liveActivityStaleDate(),
556 );
557 if (!activity) {
558 return;
559 }
560 logRegistrationDebug("live activity card armed for local work", {
561 threadTitle: input.threadTitle,
562 });
563 runRegistrationInBackground(
564 registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid),
565 "live activity arming after local task start failed",
566 );
567 } catch (error) {
568 logRegistrationError("live activity arming failed", error);
569 }
570}
571
572function readAgentActivitySnapshot(): Effect.Effect<
573 RelayAgentActivitySnapshotResponse | null,
574 never,
575 ManagedRelay.ManagedRelayClient
576> {
577 return Effect.gen(function* () {
578 if (!readRelayConfig()) return null;
579 const token = yield* relayToken("read-live-activity-registration-relay-token");
580 if (!token) {
581 return null;
582 }
583 const client = yield* ManagedRelay.ManagedRelayClient;
584 return yield* client.getAgentActivitySnapshot({ clerkToken: token });
585 }).pipe(
586 Effect.catch((error) =>
587 Effect.sync(() => {
588 logRegistrationError("agent activity snapshot read failed", error);
589 return null;
590 }),
591 ),
592 );
593}
594
595function registerLiveActivityWithRelay(
596 body: RelayLiveActivityRegistrationRequest,
597): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
598 return Effect.gen(function* () {
599 if (!readRelayConfig()) return false;
600 const token = yield* relayToken("read-live-activity-registration-relay-token");
601 if (!token) {
602 logRegistrationDebug("relay live activity registration skipped; user is not signed in");
603 return false;
604 }
605
606 const client = yield* ManagedRelay.ManagedRelayClient;
607 yield* client.registerLiveActivity({
608 clerkToken: token,
609 payload: body,
610 });
611 return true;
612 });
613}
614
615function logRegistrationError(context: string, error: unknown): void {
616 if (!__DEV__) {
617 return;
618 }
619 console.warn(`[agent-awareness] ${context}`, {
620 message: error instanceof Error ? error.message : String(error),
621 traceId: findErrorTraceId(error),
622 error,
623 });
624}
625
626function logRegistrationDebug(context: string, details?: unknown): void {
627 if (!__DEV__) {
628 return;
629 }
630 console.log(`[agent-awareness] ${context}`, details ?? "");
631}
632
633function runRegistrationInBackground(
634 operation: Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
635 context: string,
636): void {
637 void (async () => {
638 const result = await settleAsyncResult(() => runtime.runPromiseExit(operation));
639 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
640 logRegistrationError(context, squashAtomCommandFailure(result));
641 }
642 })();
643}
644
645function mergeDeviceRegistrationInput(
646 current: DeviceRegistrationInput,
647 next: DeviceRegistrationInput,
648): DeviceRegistrationInput {
649 const observedPushToken = next.observedPushToken ?? current.observedPushToken;
650 return observedPushToken ? { observedPushToken } : {};
651}
652
653function registrationAddsInformation(
654 current: DeviceRegistrationInput,
655 next: DeviceRegistrationInput,
656): boolean {
657 return (
658 next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken
659 );
660}
661
662function startPendingDeviceRegistration(): void {
663 if (activeDeviceRegistration || !pendingDeviceRegistration) {
664 return;
665 }
666
667 const next = pendingDeviceRegistration;
668 pendingDeviceRegistration = null;
669 const generation = deviceRegistrationGeneration;
670 logRegistrationDebug("device registration started", {
671 generation,
672 hasObservedPushToken: next.input.observedPushToken !== undefined,
673 });
674 if (registrationStatus !== "registered") {
675 setRegistrationStatus("pending");
676 }
677 const registration = {
678 input: next.input,
679 operation: Promise.resolve(),
680 };
681 activeDeviceRegistration = registration;
682 registration.operation = (async () => {
683 const result = await settleAsyncResult(() =>
684 runtime.runPromiseExit(registerDevice(next.input, generation)),
685 );
686 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
687 // A transient failure on a later refresh (e.g. token rotation) leaves
688 // the prior accepted registration intact on the relay, so an already
689 // registered device stays "registered" rather than flipping the
690 // settings toggles off.
691 if (registrationStatus !== "registered") {
692 setRegistrationStatus("failed");
693 }
694 logRegistrationError(next.context, squashAtomCommandFailure(result));
695 }
696 logRegistrationDebug("device registration finished", { generation });
697 if (activeDeviceRegistration === registration) {
698 activeDeviceRegistration = null;
699 }
700 startPendingDeviceRegistration();
701 })();
702}
703
704function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void {
705 if (
706 activeDeviceRegistration &&
707 !registrationAddsInformation(activeDeviceRegistration.input, input)
708 ) {
709 logRegistrationDebug("device registration coalesced with active request", {
710 generation: deviceRegistrationGeneration,
711 });
712 return;
713 }
714
715 logRegistrationDebug("device registration enqueued", {
716 generation: deviceRegistrationGeneration,
717 hasActiveRegistration: activeDeviceRegistration !== null,
718 hasPendingRegistration: pendingDeviceRegistration !== null,
719 });
720 pendingDeviceRegistration = pendingDeviceRegistration
721 ? {
722 input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input),
723 context,
724 }
725 : { input, context };
726 startPendingDeviceRegistration();
727}
728
729function registerDevice(
730 input: RegisterDeviceInput = {},
731 expectedGeneration = deviceRegistrationGeneration,
732): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
733 return Effect.gen(function* () {
734 if (!canRegisterPushNotifications()) {
735 logRegistrationDebug("device registration skipped; platform does not support it");
736 return;
737 }
738
739 logRegistrationDebug("device registration loading local state", { expectedGeneration });
740 const [deviceId, storedPreferences] = yield* Effect.all([
741 Effect.tryPromise({
742 try: () => loadOrCreateAgentAwarenessDeviceId(),
743 catch: (cause) =>
744 new AgentAwarenessOperationError({
745 operation: "load-device-registration-identifier",
746 cause,
747 }),
748 }),
749 Effect.tryPromise({
750 try: () => loadPreferences(),
751 catch: (cause) =>
752 new AgentAwarenessOperationError({
753 operation: "load-device-registration-preferences",
754 cause,
755 }),
756 }),
757 ]);
758 const preferences = mergeAgentAwarenessRegistrationPreferences(
759 storedPreferences,
760 input.preferencesOverride,
761 );
762 if (expectedGeneration !== deviceRegistrationGeneration) return;
763 if (relayTokenProvider && relayTokenProviderIdentity) {
764 configureAndroidAgentNotifications(
765 deviceId,
766 relayTokenProviderIdentity,
767 preferences.liveActivitiesEnabled !== false,
768 );
769 }
770 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
771 logRegistrationDebug("device registration local state ready", {
772 expectedGeneration,
773 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
774 });
775 const bundleId =
776 Platform.OS === "android"
777 ? Constants.expoConfig?.android?.package?.trim()
778 : Constants.expoConfig?.ios?.bundleIdentifier?.trim();
779 yield* registerDeviceWithRelay(
780 makeRelayDeviceRegistrationRequest({
781 deviceId,
782 label:
783 Constants.deviceName?.trim() ||
784 (Platform.OS === "android" ? "Android device" : "iOS device"),
785 ...(Platform.OS === "android"
786 ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) }
787 : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }),
788 appVersion: Constants.expoConfig?.version,
789 ...(bundleId ? { bundleId } : {}),
790 ...(Platform.OS === "ios"
791 ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) }
792 : {}),
793 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
794 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
795 preferences,
796 }),
797 expectedGeneration,
798 );
799 });
800}
801
802function registerDeviceForCurrentUser(): Effect.Effect<
803 void,
804 unknown,
805 ManagedRelay.ManagedRelayClient
806> {
807 return registerDevice(undefined);
808}
809
810function ensurePushTokenListener(): void {
811 if (pushTokenSubscription || !canRegisterPushNotifications()) {
812 return;
813 }
814
815 pushTokenSubscription = Notifications.addPushTokenListener((token) => {
816 if (
817 token.type === Platform.OS &&
818 typeof token.data === "string" &&
819 token.data.trim().length > 0
820 ) {
821 enqueueDeviceRegistration(
822 { observedPushToken: token.data.trim() },
823 "native push token rotation registration failed",
824 );
825 }
826 });
827}
828
829// Re-registering activity tokens on foreground makes the relay replay the
830// current aggregate to this device, which updates content that drifted while
831// pushes could not be delivered and ends orphaned activities whose end push
832// never arrived. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid
833// foreground/sign-in bursts collapse to one registration, but returning after
834// real time away still replays.)
835function ensureAppStateListener(): void {
836 if (appStateSubscription || !canRegisterPushNotifications()) {
837 return;
838 }
839
840 appStateSubscription = AppState.addEventListener("change", (state) => {
841 if (state !== "active") {
842 return;
843 }
844 enqueueDeviceRegistration({}, "device registration after app foreground failed");
845 runRegistrationInBackground(
846 refreshActiveLiveActivityRemoteRegistration(),
847 "active live activity reconciliation after app foreground failed",
848 );
849 });
850}
851
852function endLocalLiveActivities(context: string): void {
853 if (!canRegisterRemoteLiveActivities()) {
854 return;
855 }
856 try {
857 for (const activity of getAgentLiveActivities()) {
858 activity.end("immediate").catch((error: unknown) => {
859 logRegistrationError(context, error);
860 });
861 }
862 } catch (error) {
863 logRegistrationError(context, error);
864 }
865}
866
867export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
868 if (!canRegisterPushNotifications()) {
869 return;
870 }
871
872 environmentConnections.set(connection.environmentId, connection);
873 ensurePushTokenListener();
874 ensureAppStateListener();
875 enqueueDeviceRegistration({}, "device registration failed");
876 runRegistrationInBackground(
877 refreshActiveLiveActivityRemoteRegistration(),
878 "active live activity registration after environment connection failed",
879 );
880}
881
882function removeAgentAwarenessConnection(environmentId: EnvironmentId): void {
883 environmentConnections.delete(environmentId);
884}
885
886export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void {
887 removeAgentAwarenessConnection(environmentId);
888}
889
890export function refreshAgentAwarenessRegistration(): Effect.Effect<
891 void,
892 never,
893 ManagedRelay.ManagedRelayClient
894> {
895 return registerDeviceForCurrentUser().pipe(
896 Effect.catch((error) =>
897 Effect.sync(() => {
898 // Same rationale as the queued path: a failed refresh does not undo an
899 // already accepted registration.
900 if (registrationStatus !== "registered") {
901 setRegistrationStatus("failed");
902 }
903 logRegistrationError("device registration refresh failed", error);
904 }),
905 ),
906 );
907}
908
909export function updateAgentAwarenessRegistrationPreferences(
910 preferencesOverride: Partial<Preferences>,
911): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
912 return registerDevice({ preferencesOverride }).pipe(
913 Effect.tapError((error) =>
914 Effect.sync(() => {
915 if (registrationStatus !== "registered") {
916 setRegistrationStatus("failed");
917 }
918 logRegistrationError("device preference registration refresh failed", error);
919 }),
920 ),
921 );
922}
923
924export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
925 environmentConnections.clear();
926 pushTokenSubscription?.remove();
927 pushTokenSubscription = null;
928 appStateSubscription?.remove();
929 appStateSubscription = null;
930 if (activeLiveActivityRegistrationRetry) {
931 clearTimeout(activeLiveActivityRegistrationRetry);
932 activeLiveActivityRegistrationRetry = null;
933 }
934 relayTokenProvider = null;
935 relayTokenProviderIdentity = null;
936 deviceRegistrationGeneration++;
937 activeDeviceRegistration = null;
938 pendingDeviceRegistration = null;
939 registrationStatus = "unknown";
940 androidDeviceReplayedAt = null;
941 registrationStatusListeners.clear();
942 registeredActivityPushTokens.clear();
943}
944
945export function unregisterAgentAwarenessDeviceForCurrentUser(
946 tokenProvider: () => Promise<string | null>,
947): Effect.Effect<void, never, ManagedRelay.ManagedRelayClient> {
948 return Effect.gen(function* () {
949 const deviceId = yield* Effect.tryPromise({
950 try: () => loadAgentAwarenessDeviceId(),
951 catch: (cause) =>
952 new AgentAwarenessOperationError({
953 operation: "load-device-unregistration-identifier",
954 cause,
955 }),
956 });
957 if (!deviceId) {
958 return;
959 }
960 yield* unregisterDeviceWithRelay({ deviceId, tokenProvider });
961 }).pipe(
962 Effect.catch((error) =>
963 Effect.sync(() => {
964 logRegistrationError("device unregistration failed", error);
965 }),
966 ),
967 );
968}
969
970export function registerLiveActivityPushToken(input: {
971 readonly activity: LiveActivity<AgentActivityProps>;
972}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
973 return Effect.gen(function* () {
974 if (!canRegisterRemoteLiveActivities()) {
975 return false;
976 }
977
978 const activityPushToken = yield* Effect.tryPromise({
979 try: () => input.activity.getPushToken(),
980 catch: (cause) =>
981 new AgentAwarenessOperationError({
982 operation: "read-live-activity-push-token",
983 cause,
984 }),
985 });
986 if (!activityPushToken) {
987 if (activityPushTokenListeners.has(input.activity)) {
988 logRegistrationDebug(
989 "live activity push token not available yet; token listener already registered",
990 {
991 connectionCount: environmentConnections.size,
992 },
993 );
994 return false;
995 }
996
997 logRegistrationDebug(
998 "live activity push token not available yet; listening for token event",
999 {
1000 connectionCount: environmentConnections.size,
1001 },
1002 );
1003 activityPushTokenListeners.add(input.activity);
1004 input.activity.addPushTokenListener((event) => {
1005 if (event.pushToken) {
1006 logRegistrationDebug("live activity push token event received", {
1007 tokenSuffix: event.pushToken.slice(-8),
1008 });
1009 runRegistrationInBackground(
1010 registerLiveActivityPushTokenValue({
1011 activityPushToken: event.pushToken,
1012 }),
1013 "live activity token listener registration failed",
1014 );
1015 }
1016 });
1017 return false;
1018 }
1019
1020 return yield* registerLiveActivityPushTokenValue({
1021 activityPushToken,
1022 });
1023 });
1024}
1025
1026function registerLiveActivityPushTokenValue(input: {
1027 readonly activityPushToken: string;
1028}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
1029 return Effect.gen(function* () {
1030 const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken);
1031 if (
1032 acceptedAt !== undefined &&
1033 Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS
1034 ) {
1035 return true;
1036 }
1037 const deviceId = yield* Effect.tryPromise({
1038 try: () => loadOrCreateAgentAwarenessDeviceId(),
1039 catch: (cause) =>
1040 new AgentAwarenessOperationError({
1041 operation: "load-live-activity-registration-identifier",
1042 cause,
1043 }),
1044 });
1045 const registered = yield* registerLiveActivityWithRelay({
1046 deviceId,
1047 activityPushToken: input.activityPushToken,
1048 });
1049 if (registered) {
1050 registeredActivityPushTokens.set(input.activityPushToken, Date.now());
1051 logRegistrationDebug("live activity push token registered", {
1052 tokenSuffix: input.activityPushToken.slice(-8),
1053 });
1054 }
1055 return registered;
1056 });
1057}
1058
1059function scheduleActiveLiveActivityRegistrationRetry(): void {
1060 if (activeLiveActivityRegistrationRetry || !relayTokenProvider) {
1061 return;
1062 }
1063
1064 activeLiveActivityRegistrationRetry = setTimeout(() => {
1065 activeLiveActivityRegistrationRetry = null;
1066 runRegistrationInBackground(
1067 refreshActiveLiveActivityRemoteRegistration(),
1068 "active live activity token retry failed",
1069 );
1070 }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS);
1071}
1072
1073export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
1074 void,
1075 never,
1076 ManagedRelay.ManagedRelayClient
1077> {
1078 return Effect.gen(function* () {
1079 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
1080 return;
1081 }
1082
1083 let activities = yield* Effect.try({
1084 try: () => getAgentLiveActivities(),
1085 catch: (cause) =>
1086 new AgentAwarenessOperationError({
1087 operation: "list-active-live-activities",
1088 cause,
1089 }),
1090 }).pipe(
1091 Effect.catch((error) =>
1092 Effect.sync(() => {
1093 logRegistrationError("active live activity lookup failed", error);
1094 return [] as ReadonlyArray<LiveActivity<AgentActivityProps>>;
1095 }),
1096 ),
1097 );
1098
1099 // The relay tracks exactly one card per device; if concurrent arming ever
1100 // produced extras, end them so only one keeps receiving updates.
1101 if (activities.length > 1) {
1102 for (const extra of activities.slice(1)) {
1103 extra.end("immediate").catch((error: unknown) => {
1104 logRegistrationError("duplicate live activity cleanup failed", error);
1105 });
1106 }
1107 activities = activities.slice(0, 1);
1108 }
1109
1110 // Activities are only ever created here, in the foreground, where the
1111 // update token can be observed and registered immediately — the relay
1112 // never remote-starts one (background push-to-start wakes proved too
1113 // unreliable to hand the token over). Arming is conditional: the relay is
1114 // asked what the card would show first, so an idle open never creates an
1115 // empty lock-screen card, and an armed card is born with the real
1116 // aggregate instead of a placeholder.
1117 if (activities.length === 0) {
1118 const preferences = yield* Effect.tryPromise({
1119 try: () => loadPreferences(),
1120 catch: (cause) =>
1121 new AgentAwarenessOperationError({
1122 operation: "load-live-activity-prime-preferences",
1123 cause,
1124 }),
1125 }).pipe(Effect.orElseSucceed(() => null));
1126 // The toggle defaults to on: an unset preference (fresh install) must
1127 // prime, so only an explicit false blocks it.
1128 if (preferences?.liveActivitiesEnabled !== false) {
1129 const snapshot = yield* readAgentActivitySnapshot();
1130 // The snapshot request yields; an arm-on-send may have created the
1131 // card in the meantime. Re-check so two cards are never started.
1132 const armedMeanwhile = yield* Effect.try({
1133 try: () => getAgentLiveActivities(),
1134 catch: () => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>,
1135 }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
1136 if (armedMeanwhile.length > 0) {
1137 activities = [...armedMeanwhile];
1138 } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) {
1139 const aggregate = snapshot.aggregate;
1140 const primed = yield* Effect.try({
1141 try: () =>
1142 startAgentLiveActivity(
1143 {
1144 title: aggregate.title,
1145 subtitle: aggregate.subtitle,
1146 activeCount: aggregate.activeCount,
1147 updatedAt: aggregate.updatedAt,
1148 activities: aggregate.activities,
1149 },
1150 liveActivityStaleDate(),
1151 ),
1152 catch: (cause) =>
1153 new AgentAwarenessOperationError({
1154 operation: "prime-live-activity",
1155 cause,
1156 }),
1157 }).pipe(
1158 Effect.catch((error) =>
1159 Effect.sync(() => {
1160 logRegistrationError("live activity priming failed", error);
1161 return null;
1162 }),
1163 ),
1164 );
1165 if (primed) {
1166 logRegistrationDebug("live activity card primed", {
1167 activeCount: aggregate.activeCount,
1168 });
1169 activities = [primed];
1170 }
1171 }
1172 }
1173 }
1174
1175 const registrationResults = yield* Effect.forEach(activities, (activity) =>
1176 registerLiveActivityPushToken({ activity }).pipe(
1177 Effect.map((registered) => !registered),
1178 Effect.catch((error) =>
1179 Effect.sync(() => {
1180 logRegistrationError("active live activity token registration failed", error);
1181 return true;
1182 }),
1183 ),
1184 ),
1185 );
1186
1187 if (registrationResults.some(Boolean)) {
1188 scheduleActiveLiveActivityRegistrationRetry();
1189 }
1190 });
1191}