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 Clock from "effect/Clock";
9import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";
11import * as Semaphore from "effect/Semaphore";
12import { AppState, Platform } from "react-native";
13import type { EnvironmentId } from "@t3tools/contracts";
14import {
15 type RelayDeviceRegistrationRequest,
16 type RelayAgentActivitySnapshotResponse,
17 type RelayLiveActivityRegistrationRequest,
18} from "@t3tools/contracts/relay";
19import { findErrorTraceId } from "@t3tools/client-runtime/errors";
20import { ManagedRelay } from "@t3tools/client-runtime/relay";
21import {
22 isAtomCommandInterrupted,
23 settleAsyncResult,
24 squashAtomCommandFailure,
25} from "@t3tools/client-runtime/state/runtime";
26
27import { runtime } from "../../lib/runtime";
28import { appAtomRegistry } from "../../state/atom-registry";
29import { environmentServerConfigsAtom } from "../../state/server";
30import type { Preferences } from "../../persistence/mobile-preferences";
31import {
32 clearAgentAwarenessRegistrationRecord,
33 loadAgentAwarenessDeviceId,
34 loadAgentAwarenessRegistrationRecord,
35 loadOrCreateAgentAwarenessDeviceId,
36 loadPreferences,
37 saveAgentAwarenessRegistrationRecord,
38} from "../../persistence/imperative";
39import type { AgentActivityProps } from "../../widgets/AgentActivity";
40import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline";
41import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget";
42import {
43 dismissEndedAgentLiveActivities,
44 getAgentLiveActivities,
45 startAgentLiveActivity,
46} from "./agentLiveActivity";
47import { resolveCloudPublicConfig } from "../cloud/publicConfig";
48import { supportsAgentAwarenessPush } from "./capabilities";
49import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
50
51const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
52
53const AgentAwarenessOperation = Schema.Literals([
54 "read-notification-permissions",
55 "read-native-push-token",
56 "read-device-registration-relay-token",
57 "read-device-unregistration-relay-token",
58 "read-live-activity-registration-relay-token",
59 "load-device-registration-identifier",
60 "load-device-registration-preferences",
61 "load-device-unregistration-identifier",
62 "read-live-activity-push-token",
63 "load-live-activity-registration-identifier",
64 "list-active-live-activities",
65 "load-live-activity-prime-preferences",
66 "prime-live-activity",
67 "load-registration-record",
68]);
69
70export class AgentAwarenessOperationError extends Schema.TaggedError<AgentAwarenessOperationError>()(
71 "AgentAwarenessOperationError",
72 {
73 operation: AgentAwarenessOperation,
74 cause: Schema.Defect(),
75 },
76) {
77 override get message(): string {
78 return `Agent awareness operation ${this.operation} failed.`;
79 }
80}
81
82export type AgentAwarenessRegistrationError =
83 | AgentAwarenessOperationError
84 | ManagedRelay.ManagedRelayClientError;
85
86// ActivityKit returns new wrappers per id; serialize by id to prevent stale-token registration.
87const activityPushTokenLocks = new Map<string, Semaphore.Semaphore>();
88// Each accepted registration replays the relay aggregate, repairing drifted or orphaned activities.
89const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
90// Match the relay's STALE_AFTER_SECONDS so activities expire even if registration fails.
91const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000;
92const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS);
93const registeredActivityPushTokens = new Map<string, number>();
94// Refreshes can overlap (sign-in, foreground, retry); only the newest read may
95// repaint, so a slow older response never overwrites newer rows.
96let widgetSnapshotReads = 0;
97let androidDeviceReplayedAt: number | null = null;
98let pushTokenSubscription: { remove: () => void } | null = null;
99let appStateSubscription: { remove: () => void } | null = null;
100
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
146function 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 return null;
157 }
158
159 return { url: relayUrl };
160}
161
162function canRegisterRemoteLiveActivities(): boolean {
163 return Platform.OS === "ios";
164}
165
166function canRegisterPushNotifications(): boolean {
167 return Platform.OS === "ios" || Platform.OS === "android";
168}
169
170function shouldRegisterAgentAwarenessDeviceForProvider(
171 previousIdentity: string | null,
172 identity: string | undefined,
173): boolean {
174 return identity === undefined || identity !== previousIdentity;
175}
176
177export function setAgentAwarenessRelayTokenProvider(
178 provider: (() => Promise<string | null>) | null,
179 identity?: string,
180): void {
181 const isExistingIdentity =
182 provider !== null &&
183 !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);
184 if (!isExistingIdentity) {
185 // An unset JS identity may mean remount; native configure checks the persisted account on cold start.
186 if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) {
187 clearAndroidAgentNotifications();
188 }
189 if (provider) {
190 publishWidget({ kind: "loading" });
191 }
192 androidDeviceReplayedAt = null;
193 deviceRegistrationGeneration++;
194 activeDeviceRegistration = null;
195 pendingDeviceRegistration = null;
196 registeredActivityPushTokens.clear();
197 }
198 relayTokenProvider = provider;
199 relayTokenProviderIdentity = provider ? (identity ?? null) : null;
200 if (!provider) {
201 clearAndroidAgentNotifications();
202 pushTokenSubscription?.remove();
203 pushTokenSubscription = null;
204 appStateSubscription?.remove();
205 appStateSubscription = null;
206 if (activeLiveActivityRegistrationRetry) {
207 clearTimeout(activeLiveActivityRegistrationRetry);
208 activeLiveActivityRegistrationRetry = null;
209 }
210 // Signed-out activities would be orphaned because the relay can no longer update or end them.
211 endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
212 publishWidget({ kind: "signed-out" });
213 setRegistrationStatus("unknown");
214 void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => {
215 logRegistrationError("clear registration record on sign-out failed", error);
216 });
217 return;
218 }
219 ensurePushTokenListener();
220 ensureAppStateListener();
221 runRegistrationInBackground(
222 refreshActiveLiveActivityRemoteRegistration(),
223 "active live activity registration after cloud sign-in failed",
224 );
225 if (isExistingIdentity) {
226 if (registrationStatus !== "registered") {
227 enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed");
228 }
229 return;
230 }
231 enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
232}
233
234// Provider remounts retain a valid relay registration; teardown must preserve it and lock-screen activities.
235export function releaseAgentAwarenessRelayTokenProvider(): void {
236 relayTokenProvider = null;
237 relayTokenProviderIdentity = null;
238 deviceRegistrationGeneration++;
239 activeDeviceRegistration = null;
240 pendingDeviceRegistration = null;
241 androidDeviceReplayedAt = null;
242 pushTokenSubscription?.remove();
243 pushTokenSubscription = null;
244 appStateSubscription?.remove();
245 appStateSubscription = null;
246 if (activeLiveActivityRegistrationRetry) {
247 clearTimeout(activeLiveActivityRegistrationRetry);
248 activeLiveActivityRegistrationRetry = null;
249 }
250}
251
252function iosMajorVersion(): number {
253 const version = Platform.Version;
254 if (typeof version === "number") {
255 return Math.floor(version);
256 }
257 const major = Number.parseInt(version.split(".")[0] ?? "", 10);
258 return Number.isFinite(major) ? major : 18;
259}
260
261const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushTokenRegistration")(
262 function* (observedPushToken?: string) {
263 if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) {
264 return { notificationsEnabled: false, pushToken: null, notificationPermissionGranted: false };
265 }
266 const permissions = yield* Effect.tryPromise({
267 try: () => Notifications.getPermissionsAsync(),
268 catch: (cause) =>
269 new AgentAwarenessOperationError({
270 operation: "read-notification-permissions",
271 cause,
272 }),
273 });
274 // Android FCM data delivery is independent of notification presentation permission.
275 if (!permissions.granted && Platform.OS !== "android") {
276 return { notificationsEnabled: false, pushToken: null, notificationPermissionGranted: false };
277 }
278 const alertsEnabled =
279 permissions.granted &&
280 (Platform.OS === "android" && Number(Platform.Version) >= 26
281 ? yield* Effect.tryPromise({
282 try: () => Notifications.getNotificationChannelAsync("agent-alerts"),
283 catch: (cause) =>
284 new AgentAwarenessOperationError({
285 operation: "read-notification-permissions",
286 cause,
287 }),
288 }).pipe(
289 Effect.map((channel) => channel?.importance !== Notifications.AndroidImportance.NONE),
290 Effect.orElseSucceed(() => false),
291 )
292 : true);
293 if (observedPushToken) {
294 return {
295 notificationsEnabled: alertsEnabled,
296 pushToken: observedPushToken,
297 notificationPermissionGranted: permissions.granted,
298 };
299 }
300 const token = yield* Effect.tryPromise({
301 try: () => Notifications.getDevicePushTokenAsync(),
302 catch: (cause) =>
303 new AgentAwarenessOperationError({
304 operation: "read-native-push-token",
305 cause,
306 }),
307 }).pipe(
308 Effect.tapError((error) =>
309 Effect.sync(() => {
310 logRegistrationError("native push token lookup failed", error);
311 }),
312 ),
313 Effect.orElseSucceed(() => null),
314 );
315 const pushToken =
316 token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0
317 ? token.data.trim()
318 : null;
319 return {
320 notificationsEnabled: alertsEnabled && pushToken !== null,
321 pushToken,
322 notificationPermissionGranted: permissions.granted,
323 };
324 },
325);
326
327const relayToken = Effect.fn("remoteRegistration.relayToken")(function* (
328 operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token",
329) {
330 const provider = relayTokenProvider;
331 if (!provider) {
332 return null;
333 }
334 return yield* Effect.tryPromise({
335 try: provider,
336 catch: (cause) => new AgentAwarenessOperationError({ operation, cause }),
337 });
338});
339
340function registrationSignature(body: RelayDeviceRegistrationRequest): string {
341 return [
342 body.deviceId,
343 body.pushToken ?? "",
344 body.bundleId ?? "",
345 body.apsEnvironment ?? "",
346 body.appVersion ?? "",
347 body.label,
348 body.platform,
349 body.iosMajorVersion,
350 body.androidApiLevel,
351 body.preferences.notificationsEnabled,
352 body.preferences.liveActivitiesEnabled,
353 body.preferences.notifyOnApproval,
354 body.preferences.notifyOnInput,
355 body.preferences.notifyOnCompletion,
356 body.preferences.notifyOnFailure,
357 ].join("|");
358}
359
360const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* (
361 body: RelayDeviceRegistrationRequest,
362 expectedGeneration: number,
363): Effect.fn.Return<void, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
364 if (expectedGeneration !== deviceRegistrationGeneration) {
365 return;
366 }
367 const relayConfig = readRelayConfig();
368 if (!relayConfig) {
369 setRegistrationStatus("unknown");
370 return;
371 }
372 const token = yield* relayToken("read-device-registration-relay-token");
373 if (expectedGeneration !== deviceRegistrationGeneration) {
374 return;
375 }
376 if (!token) {
377 setRegistrationStatus("unknown");
378 return;
379 }
380
381 const identity = relayTokenProviderIdentity ?? "";
382 const persisted = yield* Effect.tryPromise({
383 try: () => loadAgentAwarenessRegistrationRecord(),
384 catch: (cause) =>
385 new AgentAwarenessOperationError({ operation: "load-registration-record", cause }),
386 }).pipe(Effect.orElseSucceed(() => null));
387 if (expectedGeneration !== deviceRegistrationGeneration) {
388 // Signed out while the record loaded — do not resurrect the cleared
389 // record or report the previous account's registration as current.
390 return;
391 }
392 const payload = body;
393 const signature = `${relayConfig.url}|${registrationSignature(payload)}`;
394 // Android registration also silently replays the current card. Collapse
395 // foreground bursts, but repair missed pushes on cold start or a return
396 // after time away, just like re-registering an iOS activity token.
397 const now = yield* Clock.currentTimeMillis;
398 const needsAndroidReplay =
399 body.platform === "android" &&
400 (androidDeviceReplayedAt === null ||
401 now - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS);
402 if (
403 persisted &&
404 persisted.identity === identity &&
405 persisted.signature === signature &&
406 !needsAndroidReplay
407 ) {
408 setRegistrationStatus("registered");
409 return;
410 }
411
412 const client = yield* ManagedRelay.ManagedRelayClient;
413 yield* client.registerDevice({
414 clerkToken: token,
415 payload,
416 });
417 if (expectedGeneration !== deviceRegistrationGeneration) {
418 // Signed out while the request was in flight: the sign-out path already
419 // reset the status and cleared the record for the next account, so a
420 // stale success must not overwrite either.
421 return;
422 }
423 if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis;
424 setRegistrationStatus("registered");
425 yield* Effect.promise(() =>
426 saveAgentAwarenessRegistrationRecord({
427 identity,
428 signature,
429 }).catch((error: unknown) => {
430 logRegistrationError("persist registration record failed", error);
431 }),
432 );
433});
434
435const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDeviceWithRelay")(
436 function* (input: {
437 readonly deviceId: string;
438 readonly tokenProvider: () => Promise<string | null>;
439 }): Effect.fn.Return<void, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
440 if (!readRelayConfig()) return;
441 const token = yield* Effect.tryPromise({
442 try: input.tokenProvider,
443 catch: (cause) =>
444 new AgentAwarenessOperationError({
445 operation: "read-device-unregistration-relay-token",
446 cause,
447 }),
448 });
449 if (!token) {
450 return;
451 }
452
453 const client = yield* ManagedRelay.ManagedRelayClient;
454 yield* client.unregisterDevice({
455 clerkToken: token,
456 deviceId: input.deviceId,
457 });
458 },
459);
460
461function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean {
462 return (
463 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
464 .agentActivityPublishing !== false
465 );
466}
467
468export function armAgentAwarenessLiveActivityForLocalWork(input: {
469 readonly environmentId: EnvironmentId;
470 readonly threadTitle: string;
471 readonly projectTitle: string;
472}): void {
473 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
474 return;
475 }
476 if (!environmentPublishesAgentActivity(input.environmentId)) {
477 return;
478 }
479 void loadPreferences()
480 .catch(() => null)
481 .then((preferences) => {
482 if (preferences?.liveActivitiesEnabled === false) {
483 return;
484 }
485 armAgentAwarenessLiveActivityForLocalWorkNow(input);
486 });
487}
488
489function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
490 readonly threadTitle: string;
491 readonly projectTitle: string;
492}): void {
493 try {
494 if (getAgentLiveActivities().length > 0) {
495 return;
496 }
497 const nowIso = new Date(Date.now()).toISOString();
498 const activity = startAgentLiveActivity(
499 {
500 title: "T3 Code",
501 subtitle: "Agent work in progress",
502 activeCount: 1,
503 updatedAt: nowIso,
504 activities: [
505 {
506 environmentId: "",
507 threadId: "",
508 projectTitle: input.projectTitle,
509 threadTitle: input.threadTitle,
510 modelTitle: "",
511 phase: "starting",
512 status: "Connecting",
513 updatedAt: nowIso,
514 deepLink: "/",
515 },
516 ],
517 },
518 liveActivityStaleDate(),
519 );
520 if (!activity) {
521 return;
522 }
523 runRegistrationInBackground(
524 registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid),
525 "live activity arming after local task start failed",
526 );
527 } catch (error) {
528 logRegistrationError("live activity arming failed", error);
529 }
530}
531
532const readAgentActivitySnapshot: () => Effect.Effect<
533 RelayAgentActivitySnapshotResponse | null,
534 never,
535 ManagedRelay.ManagedRelayClient
536> = Effect.fn("remoteRegistration.readAgentActivitySnapshot")(
537 function* () {
538 if (!readRelayConfig()) return null;
539 const token = yield* relayToken("read-live-activity-registration-relay-token");
540 if (!token) {
541 return null;
542 }
543 const client = yield* ManagedRelay.ManagedRelayClient;
544 return yield* client.getAgentActivitySnapshot({ clerkToken: token });
545 },
546 Effect.catch((error) =>
547 Effect.sync(() => {
548 logRegistrationError("agent activity snapshot read failed", error);
549 return null;
550 }),
551 ),
552);
553
554const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLiveActivityWithRelay")(
555 function* (
556 body: RelayLiveActivityRegistrationRequest,
557 ): Effect.fn.Return<boolean, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
558 if (!readRelayConfig()) return false;
559 const token = yield* relayToken("read-live-activity-registration-relay-token");
560 if (!token) {
561 return false;
562 }
563
564 const client = yield* ManagedRelay.ManagedRelayClient;
565 yield* client.registerLiveActivity({
566 clerkToken: token,
567 payload: body,
568 });
569 return true;
570 },
571);
572
573function logRegistrationError(context: string, error: unknown): void {
574 if (!__DEV__) {
575 return;
576 }
577 console.warn(`[agent-awareness] ${context}`, {
578 message: error instanceof Error ? error.message : String(error),
579 traceId: findErrorTraceId(error),
580 error,
581 });
582}
583
584function runRegistrationInBackground<E>(
585 operation: Effect.Effect<unknown, E, ManagedRelay.ManagedRelayClient>,
586 context: string,
587): void {
588 void (async () => {
589 const result = await settleAsyncResult(() => runtime.runPromiseExit(operation));
590 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
591 logRegistrationError(context, squashAtomCommandFailure(result));
592 }
593 })();
594}
595
596function mergeDeviceRegistrationInput(
597 current: DeviceRegistrationInput,
598 next: DeviceRegistrationInput,
599): DeviceRegistrationInput {
600 const observedPushToken = next.observedPushToken ?? current.observedPushToken;
601 return observedPushToken ? { observedPushToken } : {};
602}
603
604function registrationAddsInformation(
605 current: DeviceRegistrationInput,
606 next: DeviceRegistrationInput,
607): boolean {
608 return (
609 next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken
610 );
611}
612
613function startPendingDeviceRegistration(): void {
614 if (activeDeviceRegistration || !pendingDeviceRegistration) {
615 return;
616 }
617
618 const next = pendingDeviceRegistration;
619 pendingDeviceRegistration = null;
620 const generation = deviceRegistrationGeneration;
621 if (registrationStatus !== "registered") {
622 setRegistrationStatus("pending");
623 }
624 const registration = {
625 input: next.input,
626 operation: Promise.resolve(),
627 };
628 activeDeviceRegistration = registration;
629 registration.operation = (async () => {
630 const result = await settleAsyncResult(() =>
631 runtime.runPromiseExit(registerDevice(next.input, generation)),
632 );
633 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
634 // Refresh failures leave the relay's accepted registration intact; preserve registered status.
635 if (registrationStatus !== "registered") {
636 setRegistrationStatus("failed");
637 }
638 logRegistrationError(next.context, squashAtomCommandFailure(result));
639 }
640 if (activeDeviceRegistration === registration) {
641 activeDeviceRegistration = null;
642 }
643 startPendingDeviceRegistration();
644 })();
645}
646
647function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void {
648 if (
649 activeDeviceRegistration &&
650 !registrationAddsInformation(activeDeviceRegistration.input, input)
651 ) {
652 return;
653 }
654
655 pendingDeviceRegistration = pendingDeviceRegistration
656 ? {
657 input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input),
658 context,
659 }
660 : { input, context };
661 startPendingDeviceRegistration();
662}
663
664const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* (
665 input: RegisterDeviceInput = {},
666 expectedGeneration = deviceRegistrationGeneration,
667): Effect.fn.Return<void, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
668 if (!canRegisterPushNotifications()) {
669 return;
670 }
671
672 const [deviceId, storedPreferences] = yield* Effect.all([
673 Effect.tryPromise({
674 try: () => loadOrCreateAgentAwarenessDeviceId(),
675 catch: (cause) =>
676 new AgentAwarenessOperationError({
677 operation: "load-device-registration-identifier",
678 cause,
679 }),
680 }),
681 Effect.tryPromise({
682 try: () => loadPreferences(),
683 catch: (cause) =>
684 new AgentAwarenessOperationError({
685 operation: "load-device-registration-preferences",
686 cause,
687 }),
688 }),
689 ]);
690 const preferences = mergeAgentAwarenessRegistrationPreferences(
691 storedPreferences,
692 input.preferencesOverride,
693 );
694 if (expectedGeneration !== deviceRegistrationGeneration) return;
695 if (relayTokenProvider && relayTokenProviderIdentity) {
696 configureAndroidAgentNotifications(
697 deviceId,
698 relayTokenProviderIdentity,
699 preferences.liveActivitiesEnabled !== false,
700 );
701 }
702 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
703 const bundleId =
704 Platform.OS === "android"
705 ? Constants.expoConfig?.android?.package?.trim()
706 : Constants.expoConfig?.ios?.bundleIdentifier?.trim();
707 yield* registerDeviceWithRelay(
708 makeRelayDeviceRegistrationRequest({
709 deviceId,
710 label:
711 Constants.deviceName?.trim() ||
712 (Platform.OS === "android" ? "Android device" : "iOS device"),
713 ...(Platform.OS === "android"
714 ? {
715 platform: "android" as const,
716 androidApiLevel: Number(Platform.Version),
717 notificationPermissionGranted: pushTokenRegistration.notificationPermissionGranted,
718 }
719 : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }),
720 appVersion: Constants.expoConfig?.version,
721 ...(bundleId ? { bundleId } : {}),
722 ...(Platform.OS === "ios"
723 ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) }
724 : {}),
725 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
726 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
727 preferences,
728 }),
729 expectedGeneration,
730 );
731});
732
733function registerDeviceForCurrentUser(): Effect.Effect<
734 void,
735 AgentAwarenessRegistrationError,
736 ManagedRelay.ManagedRelayClient
737> {
738 return registerDevice(undefined);
739}
740
741function ensurePushTokenListener(): void {
742 if (pushTokenSubscription || !canRegisterPushNotifications()) {
743 return;
744 }
745
746 pushTokenSubscription = Notifications.addPushTokenListener((token) => {
747 if (
748 token.type === Platform.OS &&
749 typeof token.data === "string" &&
750 token.data.trim().length > 0
751 ) {
752 enqueueDeviceRegistration(
753 { observedPushToken: token.data.trim() },
754 "native push token rotation registration failed",
755 );
756 }
757 });
758}
759
760function ensureAppStateListener(): void {
761 if (appStateSubscription || !canRegisterPushNotifications()) {
762 return;
763 }
764
765 appStateSubscription = AppState.addEventListener("change", (state) => {
766 if (state !== "active") {
767 return;
768 }
769 enqueueDeviceRegistration({}, "device registration after app foreground failed");
770 runRegistrationInBackground(
771 refreshActiveLiveActivityRemoteRegistration(),
772 "active live activity reconciliation after app foreground failed",
773 );
774 });
775}
776
777function publishWidget(source: AgentActivityWidgetSource): void {
778 if (!canRegisterRemoteLiveActivities()) {
779 return;
780 }
781 publishAgentActivityWidget(source).catch((error: unknown) => {
782 logRegistrationError("agent activity widget publish failed", error);
783 });
784}
785
786// Binaries built before the native dismissEnded method throw here.
787function dismissEndedLocalLiveActivities(): void {
788 try {
789 dismissEndedAgentLiveActivities();
790 } catch (error) {
791 logRegistrationError("ended live activity cleanup failed", error);
792 }
793}
794
795function endLocalLiveActivities(context: string): void {
796 if (!canRegisterRemoteLiveActivities()) {
797 return;
798 }
799 dismissEndedLocalLiveActivities();
800 try {
801 for (const activity of getAgentLiveActivities()) {
802 activity.end("immediate").catch((error: unknown) => {
803 logRegistrationError(context, error);
804 });
805 }
806 } catch (error) {
807 logRegistrationError(context, error);
808 }
809}
810
811export function refreshAgentAwarenessRegistration(): Effect.Effect<
812 void,
813 never,
814 ManagedRelay.ManagedRelayClient
815> {
816 return registerDeviceForCurrentUser().pipe(
817 Effect.catch((error) =>
818 Effect.sync(() => {
819 if (registrationStatus !== "registered") {
820 setRegistrationStatus("failed");
821 }
822 logRegistrationError("device registration refresh failed", error);
823 }),
824 ),
825 );
826}
827
828export function updateAgentAwarenessRegistrationPreferences(
829 preferencesOverride: Partial<Preferences>,
830): Effect.Effect<void, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
831 return registerDevice({ preferencesOverride }).pipe(
832 Effect.tapError((error) =>
833 Effect.sync(() => {
834 if (registrationStatus !== "registered") {
835 setRegistrationStatus("failed");
836 }
837 logRegistrationError("device preference registration refresh failed", error);
838 }),
839 ),
840 );
841}
842
843export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
844 pushTokenSubscription?.remove();
845 pushTokenSubscription = null;
846 appStateSubscription?.remove();
847 appStateSubscription = null;
848 if (activeLiveActivityRegistrationRetry) {
849 clearTimeout(activeLiveActivityRegistrationRetry);
850 activeLiveActivityRegistrationRetry = null;
851 }
852 relayTokenProvider = null;
853 relayTokenProviderIdentity = null;
854 deviceRegistrationGeneration++;
855 activeDeviceRegistration = null;
856 pendingDeviceRegistration = null;
857 registrationStatus = "unknown";
858 androidDeviceReplayedAt = null;
859 registrationStatusListeners.clear();
860 registeredActivityPushTokens.clear();
861 activityPushTokenLocks.clear();
862 widgetSnapshotReads = 0;
863}
864
865export const unregisterAgentAwarenessDeviceForCurrentUser: (
866 tokenProvider: () => Promise<string | null>,
867) => Effect.Effect<void, never, ManagedRelay.ManagedRelayClient> = Effect.fn(
868 "remoteRegistration.unregisterAgentAwarenessDeviceForCurrentUser",
869)(
870 function* (tokenProvider: () => Promise<string | null>) {
871 const deviceId = yield* Effect.tryPromise({
872 try: () => loadAgentAwarenessDeviceId(),
873 catch: (cause) =>
874 new AgentAwarenessOperationError({
875 operation: "load-device-unregistration-identifier",
876 cause,
877 }),
878 });
879 if (!deviceId) {
880 return;
881 }
882 yield* unregisterDeviceWithRelay({ deviceId, tokenProvider });
883 },
884 Effect.catch((error) =>
885 Effect.sync(() => {
886 logRegistrationError("device unregistration failed", error);
887 }),
888 ),
889);
890
891export const registerLiveActivityPushToken = Effect.fn(
892 "remoteRegistration.registerLiveActivityPushToken",
893)(function* (input: {
894 readonly activity: LiveActivity<AgentActivityProps>;
895}): Effect.fn.Return<boolean, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
896 if (!canRegisterRemoteLiveActivities()) {
897 return false;
898 }
899
900 const lock = observeLiveActivityPushTokens(input.activity);
901 return yield* lock.withPermit(
902 Effect.gen(function* () {
903 const activityPushToken = yield* Effect.tryPromise({
904 try: () => input.activity.getPushToken(),
905 catch: (cause) =>
906 new AgentAwarenessOperationError({
907 operation: "read-live-activity-push-token",
908 cause,
909 }),
910 });
911 if (!activityPushToken) {
912 return false;
913 }
914
915 return yield* registerLiveActivityPushTokenValue({ activityPushToken });
916 }),
917 );
918});
919
920// Observe every token replacement: the relay otherwise keeps pushing to the old token until APNs rejects it.
921function observeLiveActivityPushTokens(
922 activity: LiveActivity<AgentActivityProps>,
923): Semaphore.Semaphore {
924 const activityId = activity.getId();
925 const existing = activityPushTokenLocks.get(activityId);
926 if (existing) return existing;
927 const lock = Semaphore.makeUnsafe(1);
928 activityPushTokenLocks.set(activityId, lock);
929 activity.addPushTokenListener((event) => {
930 if (!event.pushToken) return;
931 runRegistrationInBackground(
932 lock.withPermit(registerLiveActivityPushTokenValue({ activityPushToken: event.pushToken })),
933 "live activity token listener registration failed",
934 );
935 });
936 return lock;
937}
938
939const registerLiveActivityPushTokenValue = Effect.fn(
940 "remoteRegistration.registerLiveActivityPushTokenValue",
941)(function* (input: {
942 readonly activityPushToken: string;
943}): Effect.fn.Return<boolean, AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient> {
944 const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken);
945 if (
946 acceptedAt !== undefined &&
947 (yield* Clock.currentTimeMillis) - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS
948 ) {
949 return true;
950 }
951 const deviceId = yield* Effect.tryPromise({
952 try: () => loadOrCreateAgentAwarenessDeviceId(),
953 catch: (cause) =>
954 new AgentAwarenessOperationError({
955 operation: "load-live-activity-registration-identifier",
956 cause,
957 }),
958 });
959 const registered = yield* registerLiveActivityWithRelay({
960 deviceId,
961 activityPushToken: input.activityPushToken,
962 });
963 if (registered) {
964 registeredActivityPushTokens.set(input.activityPushToken, yield* Clock.currentTimeMillis);
965 }
966 return registered;
967});
968
969function scheduleActiveLiveActivityRegistrationRetry(): void {
970 if (activeLiveActivityRegistrationRetry || !relayTokenProvider) {
971 return;
972 }
973
974 activeLiveActivityRegistrationRetry = setTimeout(() => {
975 activeLiveActivityRegistrationRetry = null;
976 runRegistrationInBackground(
977 refreshActiveLiveActivityRemoteRegistration(),
978 "active live activity token retry failed",
979 );
980 }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS);
981}
982
983export const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
984 "remoteRegistration.refreshActiveLiveActivityRemoteRegistration",
985)(function* (): Effect.fn.Return<void, never, ManagedRelay.ManagedRelayClient> {
986 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
987 return;
988 }
989
990 // The home-screen widget has no push path of its own, so every foreground
991 // refresh repaints it from one snapshot read. Yields undefined once the
992 // account changed or a newer read started, so a stale result is never
993 // shown or primed.
994 const generation = deviceRegistrationGeneration;
995 const isCurrentAccount = () =>
996 relayTokenProvider !== null && deviceRegistrationGeneration === generation;
997 let read = 0;
998 const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads;
999 const refreshWidget = Effect.gen(function* () {
1000 // An obsolete refresh must not supersede the current account's read.
1001 if (!isCurrentAccount()) {
1002 return undefined;
1003 }
1004 read = ++widgetSnapshotReads;
1005 const snapshot = yield* readAgentActivitySnapshot();
1006 if (!isNewestRead()) {
1007 return undefined;
1008 }
1009 if (snapshot) {
1010 publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate });
1011 } else {
1012 publishWidget({ kind: "unavailable" });
1013 }
1014 return { snapshot };
1015 });
1016
1017 dismissEndedLocalLiveActivities();
1018 let activities = yield* Effect.try({
1019 try: () => getAgentLiveActivities(),
1020 catch: (cause) =>
1021 new AgentAwarenessOperationError({
1022 operation: "list-active-live-activities",
1023 cause,
1024 }),
1025 }).pipe(
1026 Effect.catch((error) =>
1027 Effect.sync(() => {
1028 logRegistrationError("active live activity lookup failed", error);
1029 return [] as ReadonlyArray<LiveActivity<AgentActivityProps>>;
1030 }),
1031 ),
1032 );
1033
1034 // The relay tracks exactly one card per device; if concurrent arming ever
1035 // produced extras, end them so only one keeps receiving updates.
1036 const hadActivities = activities.length > 0;
1037 if (activities.length > 1) {
1038 for (const extra of activities.slice(1)) {
1039 extra.end("immediate").catch((error: unknown) => {
1040 logRegistrationError("duplicate live activity cleanup failed", error);
1041 });
1042 }
1043 activities = activities.slice(0, 1);
1044 }
1045
1046 // Activities are only ever created here, in the foreground, where the
1047 // update token can be observed and registered immediately — the relay never
1048 // remote-starts one (background push-to-start wakes proved too unreliable
1049 // to hand the token over).
1050 if (!hadActivities) {
1051 const widgetRead = yield* refreshWidget;
1052 if (!widgetRead) {
1053 return;
1054 }
1055 const { snapshot } = widgetRead;
1056 const preferences = yield* Effect.tryPromise({
1057 try: () => loadPreferences(),
1058 catch: (cause) =>
1059 new AgentAwarenessOperationError({
1060 operation: "load-live-activity-prime-preferences",
1061 cause,
1062 }),
1063 }).pipe(Effect.orElseSucceed(() => null));
1064 // A sign-out, account switch, or newer read during the preference read
1065 // makes this snapshot stale; priming now could bring old rows back.
1066 if (!isNewestRead()) {
1067 return;
1068 }
1069 if (preferences?.liveActivitiesEnabled !== false) {
1070 // The snapshot and preference reads yield; an arm-on-send may have
1071 // created the card in the meantime. Re-check so two cards are never started.
1072 const armedMeanwhile = yield* Effect.try({
1073 try: () => getAgentLiveActivities(),
1074 catch: () => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>,
1075 }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
1076 if (armedMeanwhile.length > 0) {
1077 activities = [...armedMeanwhile];
1078 } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) {
1079 const aggregate = snapshot.aggregate;
1080 const primed = yield* Effect.try({
1081 try: () =>
1082 startAgentLiveActivity(
1083 {
1084 title: aggregate.title,
1085 subtitle: aggregate.subtitle,
1086 activeCount: aggregate.activeCount,
1087 updatedAt: aggregate.updatedAt,
1088 activities: aggregate.activities,
1089 },
1090 liveActivityStaleDate(),
1091 ),
1092 catch: (cause) =>
1093 new AgentAwarenessOperationError({
1094 operation: "prime-live-activity",
1095 cause,
1096 }),
1097 }).pipe(
1098 Effect.catch((error) =>
1099 Effect.sync(() => {
1100 logRegistrationError("live activity priming failed", error);
1101 return null;
1102 }),
1103 ),
1104 );
1105 if (primed) {
1106 activities = [primed];
1107 }
1108 }
1109 }
1110 }
1111
1112 const registrationResults = yield* Effect.forEach(activities, (activity) =>
1113 registerLiveActivityPushToken({ activity }).pipe(
1114 Effect.map((registered) => !registered),
1115 Effect.catch((error) =>
1116 Effect.sync(() => {
1117 logRegistrationError("active live activity token registration failed", error);
1118 return true;
1119 }),
1120 ),
1121 ),
1122 );
1123
1124 if (registrationResults.some(Boolean)) {
1125 scheduleActiveLiveActivityRegistrationRetry();
1126 }
1127
1128 // An existing card keeps its push token registered without waiting on the
1129 // relay; the widget still repaints afterwards.
1130 if (hadActivities) {
1131 yield* refreshWidget;
1132 }
1133});