1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc";
3import {
4 type BackgroundScope,
5 type ClientActivityReportInput,
6 type EnvironmentId,
7 WS_METHODS,
8} from "@t3tools/contracts";
9import * as Clock from "effect/Clock";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Queue from "effect/Queue";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import * as SubscriptionRef from "effect/SubscriptionRef";
17import { AppState } from "react-native";
18
19import * as MobileStorage from "../persistence/mobile-storage";
20import {
21 observeMobileBackgroundActivitySubscription,
22 onRetainedMobileBackgroundScopesChange,
23 retainedMobileBackgroundScopes,
24} from "./background-activity-scopes";
25
26const REPORT_INTERVAL_MS = 25_000;
27const LEASE_TTL_MS = 45_000;
28const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }];
29
30// `AppState.currentState` is a loosely typed string that can be unset before
31// the first change event; anything outside the known states reports as unknown.
32function normalizeAppState(
33 state: string | null | undefined,
34): NonNullable<ClientActivityReportInput["appState"]> {
35 if (state === "active" || state === "inactive" || state === "background") return state;
36 return "unknown";
37}
38
39export const layerObserver = Layer.succeed(
40 EnvironmentRpcSubscriptionObserver,
41 EnvironmentRpcSubscriptionObserver.of({
42 observe: observeMobileBackgroundActivitySubscription,
43 }),
44);
45
46export const layerReporter = Layer.effectDiscard(
47 Effect.gen(function* () {
48 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
49 const storage = yield* MobileStorage.MobileStorage;
50 const clientId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
51 Effect.map((deviceId) => `mobile-${deviceId}`),
52 Effect.orElseSucceed(() => "ephemeral-mobile-client"),
53 );
54 const reportRequests = yield* Queue.sliding<void>(1);
55 const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
56 let appState = AppState.currentState;
57
58 const report = Effect.gen(function* () {
59 const observedAtMs = yield* Clock.currentTimeMillis;
60 const active = appState === "active";
61 const entries = yield* SubscriptionRef.get(registry.entries);
62 yield* Effect.forEach(
63 entries.keys(),
64 (environmentId) =>
65 registry
66 .run(
67 environmentId,
68 request(WS_METHODS.serverReportClientActivity, {
69 environmentId: environmentId as EnvironmentId,
70 clientId,
71 clientKind: "mobile",
72 visible: active,
73 focused: active,
74 recentlyInteracted: active,
75 appState: normalizeAppState(appState),
76 scopes: [
77 ...BASELINE_SCOPES,
78 ...retainedMobileBackgroundScopes(environmentId as EnvironmentId),
79 ],
80 ttlMs: LEASE_TTL_MS,
81 observedAt: DateTime.makeUnsafe(observedAtMs),
82 }),
83 )
84 .pipe(Effect.ignore),
85 { concurrency: "unbounded", discard: true },
86 );
87 }).pipe(Effect.withSpan("mobile.backgroundActivity.report"));
88
89 yield* Effect.acquireRelease(
90 Effect.sync(() => {
91 const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport);
92 const subscription = AppState.addEventListener("change", (nextState) => {
93 appState = nextState;
94 requestReport();
95 });
96 return { removeScopeListener, subscription };
97 }),
98 ({ removeScopeListener, subscription }) =>
99 Effect.sync(() => {
100 removeScopeListener();
101 subscription.remove();
102 }),
103 );
104 yield* SubscriptionRef.changes(registry.entries).pipe(
105 Stream.runForEach(() => Effect.sync(requestReport)),
106 Effect.forkScoped,
107 );
108 yield* Stream.fromQueue(reportRequests).pipe(
109 Stream.debounce("250 millis"),
110 Stream.runForEach(() => report),
111 Effect.forkScoped,
112 );
113 yield* Effect.sync(requestReport).pipe(
114 Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)),
115 Effect.forkScoped,
116 );
117 }),
118);