| 1 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; |
| 2 | import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc"; |
| 3 | import { |
| 4 | type BackgroundScope, |
| 5 | type ClientActivityReportInput, |
| 6 | type EnvironmentId, |
| 7 | WS_METHODS, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Clock from "effect/Clock"; |
| 10 | import * as DateTime from "effect/DateTime"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Queue from "effect/Queue"; |
| 14 | import * as Schedule from "effect/Schedule"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 17 | import { AppState } from "react-native"; |
| 18 | |
| 19 | import * as MobileStorage from "../persistence/mobile-storage"; |
| 20 | import { |
| 21 | observeMobileBackgroundActivitySubscription, |
| 22 | onRetainedMobileBackgroundScopesChange, |
| 23 | retainedMobileBackgroundScopes, |
| 24 | } from "./background-activity-scopes"; |
| 25 | |
| 26 | const REPORT_INTERVAL_MS = 25_000; |
| 27 | const LEASE_TTL_MS = 45_000; |
| 28 | const 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. |
| 32 | function 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 | |
| 39 | export const layerObserver = Layer.succeed( |
| 40 | EnvironmentRpcSubscriptionObserver, |
| 41 | EnvironmentRpcSubscriptionObserver.of({ |
| 42 | observe: observeMobileBackgroundActivitySubscription, |
| 43 | }), |
| 44 | ); |
| 45 | |
| 46 | export 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 | ); |