@tryterra/react-native 0.0.0-stage → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -2
  3. package/TerraReactNative.podspec +48 -0
  4. package/android/build.gradle +87 -0
  5. package/android/src/bridgeCore/kotlin/README.md +1 -0
  6. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeBootstrap.kt +133 -0
  7. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeHost.kt +32 -0
  8. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Conversions.kt +557 -0
  9. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/LauncherSlots.kt +41 -0
  10. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/ReadConversions.kt +250 -0
  11. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Rejections.kt +150 -0
  12. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
  13. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
  14. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +300 -0
  15. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +50 -0
  16. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
  17. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
  18. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +582 -0
  19. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +1569 -0
  20. package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
  21. package/android/src/main/AndroidManifest.xml +24 -0
  22. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
  23. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
  24. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
  25. package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
  26. package/docs/availability.md +396 -0
  27. package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
  28. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
  29. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +82672 -0
  30. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2186 -0
  31. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2186 -0
  32. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  33. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2163 -0
  34. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  35. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
  36. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  37. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  38. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6594 -0
  39. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
  40. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +82672 -0
  41. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2186 -0
  42. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2186 -0
  43. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  44. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2163 -0
  45. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +82672 -0
  46. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2186 -0
  47. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2186 -0
  48. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  49. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2163 -0
  50. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  51. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
  52. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
  53. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  54. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  55. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6595 -0
  56. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6513 -0
  57. package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
  58. package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
  59. package/ios/TerraBridgeCore/Conversions+Reads.swift +259 -0
  60. package/ios/TerraBridgeCore/Conversions.swift +594 -0
  61. package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
  62. package/ios/TerraBridgeCore/Generated/BridgeOps.swift +967 -0
  63. package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +2050 -0
  64. package/ios/TerraBridgeCore/Locked.swift +15 -0
  65. package/ios/TerraBridgeCore/README.md +1 -0
  66. package/ios/TerraBridgeCore/Rejections.swift +130 -0
  67. package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
  68. package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
  69. package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
  70. package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +318 -0
  71. package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
  72. package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
  73. package/ios/TerraBridgeModule.mm +79 -0
  74. package/ios/TerraBridgeModuleImpl.swift +49 -0
  75. package/ios/TerraReactNative.swift +19 -0
  76. package/lib/EndUser.d.ts +12 -0
  77. package/lib/EndUser.js +13 -0
  78. package/lib/NativeTerraBridge.d.ts +23 -0
  79. package/lib/NativeTerraBridge.js +13 -0
  80. package/lib/Terra.d.ts +223 -0
  81. package/lib/Terra.js +336 -0
  82. package/lib/TerraError.d.ts +42 -0
  83. package/lib/TerraError.js +95 -0
  84. package/lib/createTerra.d.ts +8 -0
  85. package/lib/createTerra.js +19 -0
  86. package/lib/generated/client.d.ts +128 -0
  87. package/lib/generated/client.js +256 -0
  88. package/lib/generated/codecs.d.ts +219 -0
  89. package/lib/generated/codecs.js +2066 -0
  90. package/lib/generated/errors.d.ts +13 -0
  91. package/lib/generated/errors.js +68 -0
  92. package/lib/generated/ops.d.ts +13 -0
  93. package/lib/generated/ops.js +161 -0
  94. package/lib/generated/streams.d.ts +23 -0
  95. package/lib/generated/streams.js +58 -0
  96. package/lib/generated/types.d.ts +794 -0
  97. package/lib/generated/types.js +224 -0
  98. package/lib/index.d.ts +29 -0
  99. package/lib/index.js +55 -0
  100. package/package.json +71 -4
  101. package/src/EndUser.ts +15 -0
  102. package/src/NativeTerraBridge.ts +27 -0
  103. package/src/Terra.ts +592 -0
  104. package/src/TerraError.ts +116 -0
  105. package/src/createTerra.ts +18 -0
  106. package/src/generated/client.ts +315 -0
  107. package/src/generated/codecs.ts +1989 -0
  108. package/src/generated/errors.ts +113 -0
  109. package/src/generated/ops.ts +233 -0
  110. package/src/generated/streams.ts +74 -0
  111. package/src/generated/types.ts +1222 -0
  112. package/src/index.ts +54 -0
package/src/Terra.ts ADDED
@@ -0,0 +1,592 @@
1
+ /**
2
+ * The client an app holds: namespaces over the generated ops, the token provider, and the streams.
3
+ *
4
+ * Hand-written, deliberately thin. Every method is one generated `GeneratedClient` call; what lives
5
+ * here is only what the generated layer cannot know - which runtime the app is on, how a JS
6
+ * function answers a native token request, and how one native event emitter fans out to many
7
+ * listeners. `facade.test.ts` drives every method and fails when an op in the schema has no member
8
+ * here, so this file cannot quietly fall behind the surface the way the 1.x wrapper did.
9
+ */
10
+
11
+ import { TerraError } from "./TerraError";
12
+ import { GeneratedClient, type Invoker } from "./generated/client";
13
+ import { availableOn, type OpId, type StreamId } from "./generated/ops";
14
+ import { decodeStreamDelivery, type StreamElements } from "./generated/streams";
15
+ import type {
16
+ AthleteProfile,
17
+ BodyEntry,
18
+ BodyReading,
19
+ BootstrapState,
20
+ Connection,
21
+ DataCategory,
22
+ DayTotal,
23
+ DiagnosticsState,
24
+ DietaryQuantity,
25
+ EndUser,
26
+ HealthAccess,
27
+ HealthChange,
28
+ HeartRateEntry,
29
+ HeartRateSample,
30
+ HydrationSample,
31
+ IntradaySample,
32
+ MenstrualEvent,
33
+ MenstruationFlowDay,
34
+ NutritionEntry,
35
+ PermissionStatus,
36
+ PlannedWorkout,
37
+ RestingHeartRateSample,
38
+ StoreAvailability,
39
+ WorkoutSchedulingStatus,
40
+ SleepEntry,
41
+ SleepSegment,
42
+ Source,
43
+ StepCount,
44
+ StepEntry,
45
+ SupportReport,
46
+ TerraEvent,
47
+ TimeRange,
48
+ Workout,
49
+ WorkoutEntry,
50
+ WorkoutIntensitySample,
51
+ WorkoutRoute,
52
+ WorkoutScope,
53
+ } from "./generated/types";
54
+
55
+ /** What the facade needs from the native module. `NativeTerraBridge` satisfies it; so does a double. */
56
+ export interface NativeBridge {
57
+ invoke(op: string, payload: string): Promise<string>;
58
+ subscribe(stream: string, params: string): Promise<string>;
59
+ unsubscribe(subscriptionId: string): void;
60
+ onMessage(listener: (event: { subscriptionId: string; message: string }) => void): { remove(): void };
61
+ }
62
+
63
+ export type Platform = "ios" | "android";
64
+
65
+ /** Asked for a one-shot link token, and only while `connect` establishes a new link. */
66
+ export type TokenProvider = () => Promise<string>;
67
+
68
+ export interface TerraOptions {
69
+ /**
70
+ * Called only when `connect` has to make a new link and was given no token. A restored link
71
+ * never asks, and neither does a background wake: uploads are signed by the device key.
72
+ */
73
+ readonly tokenProvider?: TokenProvider;
74
+ /** How long the native side waits for `tokenProvider`. 30 seconds by default. */
75
+ readonly tokenTimeoutMs?: number;
76
+ }
77
+
78
+ export type Unsubscribe = () => void;
79
+
80
+ /** A stream's listener. `onError` receives the error a stream ended with; a stream that completes ends quietly. */
81
+ export interface StreamListener<T> {
82
+ (element: T): void;
83
+ }
84
+
85
+ export interface Permissions {
86
+ /**
87
+ * One sheet covering both directions and everything asked before. Android answers what this call
88
+ * obtained; HealthKit will not report read grants, so iOS answers `undefined`.
89
+ *
90
+ * `source` defaults to `health`. `samsungHealth` presents Samsung Health's own consent popup and
91
+ * is Android only.
92
+ */
93
+ request(access: Partial<HealthAccess>, source?: Source): Promise<HealthAccess | undefined>;
94
+ status(category: DataCategory, source?: Source): Promise<PermissionStatus>;
95
+ writeStatus(category: DataCategory, source?: Source): Promise<PermissionStatus>;
96
+ requested(source?: Source): Promise<HealthAccess>;
97
+ /** Android only. */
98
+ readonly statuses?: (source?: Source) => Promise<Partial<Record<DataCategory, PermissionStatus>>>;
99
+ /** Android only. */
100
+ readonly backgroundReadStatus?: (source?: Source) => Promise<PermissionStatus>;
101
+ /** Android only. */
102
+ readonly historyReadStatus?: (source?: Source) => Promise<PermissionStatus>;
103
+ }
104
+
105
+ export interface Diagnostics {
106
+ state(source?: Source): Promise<DiagnosticsState>;
107
+ lastDelivery(source?: Source): Promise<Date | undefined>;
108
+ report(source?: Source): Promise<SupportReport>;
109
+ send(source?: Source): Promise<void>;
110
+ /** Everything the SDK does, as it happens. Never carries a health value. */
111
+ events(listener: StreamListener<TerraEvent>, onError?: (error: TerraError) => void): Unsubscribe;
112
+ }
113
+
114
+ export interface Background {
115
+ /** Whether the native bootstrap registered background sync at launch. False is a host setup bug. */
116
+ isRegistered(): Promise<boolean>;
117
+ /** Android only: drops the schedule. */
118
+ readonly cancel?: () => Promise<boolean>;
119
+ }
120
+
121
+ interface Changes {
122
+ /** Which days changed in the health store. Re-read through this namespace's accessor. */
123
+ changes(listener: StreamListener<HealthChange>, onError?: (error: TerraError) => void): Unsubscribe;
124
+ }
125
+
126
+ export interface BodyMetrics extends Changes {
127
+ heartRate(range: TimeRange): Promise<readonly HeartRateSample[]>;
128
+ /**
129
+ * The store's own resting heart rate, one per day it computed, never derived from `heartRate`.
130
+ * Health Connect grants it under `daily`, so on Android ask for `daily` as well as `body`.
131
+ */
132
+ restingHeartRate(range: TimeRange): Promise<readonly RestingHeartRateSample[]>;
133
+ /**
134
+ * Weight, height, BMI (iOS), body fat, lean mass, heart rate variability, VO2 max, blood pressure,
135
+ * blood glucose, oxygen saturation, temperature and respiratory rate. Each reading's `kind` says
136
+ * what it measured and fixes the unit; blood glucose keeps the unit its writer chose.
137
+ */
138
+ readings(range: TimeRange): Promise<readonly BodyReading[]>;
139
+ logHeartRate(entries: readonly HeartRateEntry[]): Promise<readonly string[]>;
140
+ log(entries: readonly BodyEntry[]): Promise<readonly string[]>;
141
+ delete(id: string): Promise<void>;
142
+ }
143
+
144
+ export interface DailyMetrics extends Changes {
145
+ steps(range: TimeRange): Promise<readonly StepCount[]>;
146
+ /**
147
+ * The store's own total for each whole local day the range touches: distance, active and basal
148
+ * energy, flights climbed and, on iOS, exercise minutes. A day with nothing recorded has no total
149
+ * rather than a zero. Steps are `steps`, which keeps writers apart.
150
+ */
151
+ totals(range: TimeRange): Promise<readonly DayTotal[]>;
152
+ /** The active energy, basal energy (iOS), distance and floors records the totals are made of. */
153
+ intraday(range: TimeRange): Promise<readonly IntradaySample[]>;
154
+ log(entries: readonly StepEntry[]): Promise<readonly string[]>;
155
+ delete(id: string): Promise<void>;
156
+ }
157
+
158
+ export interface SleepMetrics extends Changes {
159
+ segments(range: TimeRange): Promise<readonly SleepSegment[]>;
160
+ log(entries: readonly SleepEntry[]): Promise<readonly string[]>;
161
+ delete(id: string): Promise<void>;
162
+ }
163
+
164
+ export interface ActivityMetrics extends Changes {
165
+ workouts(range: TimeRange): Promise<readonly Workout[]>;
166
+ /** The GPS tracks of the workouts in the range, each naming its workout by `workoutId`. */
167
+ routes(range: TimeRange): Promise<readonly WorkoutRoute[]>;
168
+ /**
169
+ * Speed, power and cycling cadence readings in the range. iOS names each one's workout; Health
170
+ * Connect stores them free-standing, so on Android `workoutId` is absent and `start` equals `end`.
171
+ */
172
+ intensity(range: TimeRange): Promise<readonly WorkoutIntensitySample[]>;
173
+ log(entries: readonly WorkoutEntry[]): Promise<readonly string[]>;
174
+ delete(id: string): Promise<void>;
175
+ }
176
+
177
+ export interface NutritionMetrics {
178
+ /**
179
+ * One quantity per nutrient a meal recorded in the range, not grouped into meals. Quantities of
180
+ * one meal share `foodCorrelationId`. Water is not among them. Each `value` is in its nutrient's
181
+ * fixed unit: kcal for `energyConsumed`, grams for the macronutrients, milligrams for the rest.
182
+ */
183
+ quantities(range: TimeRange): Promise<readonly DietaryQuantity[]>;
184
+ /** Water drunk in the range, one sample per drink, in millilitres. */
185
+ hydration(range: TimeRange): Promise<readonly HydrationSample[]>;
186
+ log(entries: readonly NutritionEntry[]): Promise<readonly string[]>;
187
+ delete(id: string): Promise<void>;
188
+ }
189
+
190
+ /** Read-only on both platforms: neither SDK has a write route for menstruation. */
191
+ export interface MenstruationMetrics {
192
+ /**
193
+ * Logged flow days overlapping the range, one per day, each bounded by that day's local
194
+ * midnights. A day logged with no flow comes back as `none` rather than being left out.
195
+ */
196
+ flow(range: TimeRange): Promise<readonly MenstruationFlowDay[]>;
197
+ /** Ovulation tests, cervical mucus and intermenstrual bleeding logged in the range. */
198
+ events(range: TimeRange): Promise<readonly MenstrualEvent[]>;
199
+ }
200
+
201
+ /** iOS only, and read-only. */
202
+ export interface AthleteMetrics {
203
+ /**
204
+ * HealthKit's characteristics as one snapshot. No range, because characteristics have no
205
+ * history. `undefined` where the store has no notion of them at all; a store that has them and
206
+ * holds no values answers a profile whose `biologicalSex` is `notSet`.
207
+ */
208
+ profile(): Promise<AthleteProfile | undefined>;
209
+ }
210
+
211
+ /** iOS only. */
212
+ export interface PlannedWorkouts {
213
+ planned(): Promise<readonly PlannedWorkout[]>;
214
+ markComplete(id: string, at?: Date): Promise<void>;
215
+ delete(id: string): Promise<void>;
216
+ /** Whether plans can be put on the person's Apple Watch. Never prompts. */
217
+ schedulingStatus(): Promise<WorkoutSchedulingStatus>;
218
+ /**
219
+ * Shows WorkoutKit's sheet if the person has not been asked. Once authorized, every sync run
220
+ * keeps the Watch schedule in step with Terra; nothing else needs calling.
221
+ */
222
+ requestScheduling(): Promise<WorkoutSchedulingStatus>;
223
+ }
224
+
225
+ export class Terra {
226
+ readonly platform: Platform;
227
+ readonly permissions: Permissions;
228
+ readonly diagnostics: Diagnostics;
229
+ readonly background: Background;
230
+ readonly body: BodyMetrics;
231
+ readonly daily: DailyMetrics;
232
+ readonly sleep: SleepMetrics;
233
+ readonly activity: ActivityMetrics;
234
+ readonly nutrition: NutritionMetrics;
235
+ readonly menstruation: MenstruationMetrics;
236
+ /** `undefined` on Android, which has no characteristics to read. */
237
+ readonly athlete: AthleteMetrics | undefined;
238
+ /** `undefined` on Android, which has no planned workouts. */
239
+ readonly plannedWorkouts: PlannedWorkouts | undefined;
240
+
241
+ private readonly ops: GeneratedClient;
242
+ private readonly streams: StreamHub;
243
+ private readonly tokenProvider: TokenProvider | undefined;
244
+ private readonly tokenTimeoutMs: number;
245
+ /**
246
+ * The provider's registration, in flight or done. `undefined` before it starts and after it failed
247
+ * or its stream ended, so the next connect that needs it registers again.
248
+ */
249
+ private tokenSetup: Promise<void> | undefined;
250
+
251
+ constructor(native: NativeBridge, platform: Platform, options: TerraOptions = {}) {
252
+ this.platform = platform;
253
+ const invoker: Invoker = native;
254
+ const ops = new GeneratedClient(invoker);
255
+ this.ops = ops;
256
+ this.streams = new StreamHub(native);
257
+ this.tokenProvider = options.tokenProvider;
258
+ this.tokenTimeoutMs = options.tokenTimeoutMs ?? 30_000;
259
+ // Registered now, so the provider is in place before anything asks. A failure here is reported
260
+ // to the connect that needs the provider, which also tries again.
261
+ void this.tokensReady();
262
+
263
+ const on = (op: OpId) => availableOn[op].includes(platform);
264
+ const stream = <S extends StreamId>(id: S) =>
265
+ (listener: StreamListener<StreamElements[S]>, onError?: (error: TerraError) => void) =>
266
+ this.streams.subscribe(id, listener, onError);
267
+
268
+ this.permissions = {
269
+ request: async (access, source) => {
270
+ const grant = await ops.permissionsRequest({
271
+ read: access.read ?? [],
272
+ write: access.write ?? [],
273
+ workouts: access.workouts ?? ([] as readonly WorkoutScope[]),
274
+ source,
275
+ });
276
+ return grant.granted;
277
+ },
278
+ status: async (category, source) => (await ops.permissionsStatus({ category, source })).status,
279
+ writeStatus: async (category, source) => (await ops.permissionsWriteStatus({ category, source })).status,
280
+ requested: (source) => ops.permissionsRequested({ source }),
281
+ ...(on("permissions.statuses") && {
282
+ statuses: async (source?: Source) => (await ops.permissionsStatuses({ source })).statuses,
283
+ }),
284
+ ...(on("permissions.backgroundReadStatus") && {
285
+ backgroundReadStatus: async (source?: Source) => (await ops.permissionsBackgroundReadStatus({ source })).status,
286
+ }),
287
+ ...(on("permissions.historyReadStatus") && {
288
+ historyReadStatus: async (source?: Source) => (await ops.permissionsHistoryReadStatus({ source })).status,
289
+ }),
290
+ };
291
+
292
+ this.diagnostics = {
293
+ state: (source) => ops.diagnosticsState({ source }),
294
+ lastDelivery: async (source) => (await ops.diagnosticsLastDelivery({ source })).at,
295
+ report: (source) => ops.diagnosticsReport({ source }),
296
+ send: async (source) => {
297
+ await ops.diagnosticsSend({ source });
298
+ },
299
+ events: stream("diagnostics.events"),
300
+ };
301
+
302
+ this.background = {
303
+ isRegistered: async () => (await ops.backgroundIsRegistered({})).value,
304
+ ...(on("background.cancel") && { cancel: async () => (await ops.backgroundCancel({})).value }),
305
+ };
306
+
307
+ this.body = {
308
+ heartRate: async (range) => (await ops.bodyHeartRate({ range })).samples,
309
+ restingHeartRate: async (range) => (await ops.bodyRestingHeartRate({ range })).samples,
310
+ readings: async (range) => (await ops.bodyReadings({ range })).samples,
311
+ logHeartRate: async (entries) => (await ops.bodyLogHeartRate({ entries })).ids,
312
+ log: async (entries) => (await ops.bodyLog({ entries })).ids,
313
+ delete: async (id) => {
314
+ await ops.bodyDelete({ id });
315
+ },
316
+ changes: stream("body.changes"),
317
+ };
318
+
319
+ this.daily = {
320
+ steps: async (range) => (await ops.dailySteps({ range })).samples,
321
+ totals: async (range) => (await ops.dailyTotals({ range })).totals,
322
+ intraday: async (range) => (await ops.dailyIntraday({ range })).samples,
323
+ log: async (entries) => (await ops.dailyLog({ entries })).ids,
324
+ delete: async (id) => {
325
+ await ops.dailyDelete({ id });
326
+ },
327
+ changes: stream("daily.changes"),
328
+ };
329
+
330
+ this.sleep = {
331
+ segments: async (range) => (await ops.sleepSegments({ range })).samples,
332
+ log: async (entries) => (await ops.sleepLog({ entries })).ids,
333
+ delete: async (id) => {
334
+ await ops.sleepDelete({ id });
335
+ },
336
+ changes: stream("sleep.changes"),
337
+ };
338
+
339
+ this.activity = {
340
+ workouts: async (range) => (await ops.activityWorkouts({ range })).samples,
341
+ routes: async (range) => (await ops.activityRoutes({ range })).routes,
342
+ intensity: async (range) => (await ops.activityIntensity({ range })).samples,
343
+ log: async (entries) => (await ops.activityLog({ entries })).ids,
344
+ delete: async (id) => {
345
+ await ops.activityDelete({ id });
346
+ },
347
+ changes: stream("activity.changes"),
348
+ };
349
+
350
+ this.nutrition = {
351
+ quantities: async (range) => (await ops.nutritionQuantities({ range })).samples,
352
+ hydration: async (range) => (await ops.nutritionHydration({ range })).samples,
353
+ log: async (entries) => (await ops.nutritionLog({ entries })).ids,
354
+ delete: async (id) => {
355
+ await ops.nutritionDelete({ id });
356
+ },
357
+ };
358
+
359
+ this.menstruation = {
360
+ flow: async (range) => (await ops.menstruationFlow({ range })).samples,
361
+ events: async (range) => (await ops.menstruationEvents({ range })).samples,
362
+ };
363
+
364
+ this.athlete = on("athlete.profile")
365
+ ? { profile: async () => (await ops.athleteProfile({})).profile }
366
+ : undefined;
367
+
368
+ this.plannedWorkouts = on("plannedWorkouts.planned")
369
+ ? {
370
+ planned: async () => (await ops.plannedWorkoutsPlanned({})).workouts,
371
+ markComplete: async (id, at) => {
372
+ await ops.plannedWorkoutsMarkComplete({ id, at });
373
+ },
374
+ delete: async (id) => {
375
+ await ops.plannedWorkoutsDelete({ id });
376
+ },
377
+ schedulingStatus: async () => (await ops.plannedWorkoutsSchedulingStatus({})).status,
378
+ requestScheduling: async () => (await ops.plannedWorkoutsRequestScheduling({})).status,
379
+ }
380
+ : undefined;
381
+ }
382
+
383
+ /**
384
+ * The native client this runtime is attached to. Rejects with `bridge.not_bootstrapped`, carrying
385
+ * the exact line to add in `details.line`, when the app's native launch never set Terra up.
386
+ */
387
+ attach(): Promise<BootstrapState> {
388
+ return this.ops.clientAttach({});
389
+ }
390
+
391
+ /**
392
+ * Links `source` for the person `endUser` names, or restores the link already made for them.
393
+ *
394
+ * Every connect names its person: a second account on the same phone must not be handed the
395
+ * first one's link. With `token` the grant in hand is spent (a QR code, a deep link); without it
396
+ * the `tokenProvider` is asked, and only when a new link is actually needed.
397
+ */
398
+ async connect(source: Source, endUser: EndUser, options: { readonly token?: string } = {}): Promise<Connection> {
399
+ // A connect that carries its token never asks the provider, so it does not wait on it either.
400
+ if (options.token === undefined) await this.tokensReady();
401
+ return this.ops.clientConnect({ source, endUser, token: options.token });
402
+ }
403
+
404
+ async connections(): Promise<readonly Connection[]> {
405
+ return (await this.ops.clientConnections({})).connections;
406
+ }
407
+
408
+ async disconnect(source?: Source): Promise<void> {
409
+ await this.ops.clientDisconnect({ source });
410
+ }
411
+
412
+ /**
413
+ * Whether the device can serve `source` at all, and if not, why. Needs no connection and prompts
414
+ * for nothing, so an app can decide whether to offer the link before `connect`. iOS answers
415
+ * `available` or `unsupportedDevice`; Android names the reason, such as `providerAppMissing` when
416
+ * Health Connect needs installing.
417
+ */
418
+ async availability(source?: Source): Promise<StoreAvailability> {
419
+ return (await this.ops.clientAvailability({ source })).availability;
420
+ }
421
+
422
+ /** Settles once the token provider, when there is one, can be asked. */
423
+ private tokensReady(): Promise<void> {
424
+ const provider = this.tokenProvider;
425
+ if (provider === undefined) return Promise.resolve();
426
+ if (this.tokenSetup !== undefined) return this.tokenSetup;
427
+ const forget = () => {
428
+ if (this.tokenSetup === setup) this.tokenSetup = undefined;
429
+ };
430
+ const setup = this.answerTokens(provider, this.tokenTimeoutMs, forget);
431
+ this.tokenSetup = setup;
432
+ // Handles the failure, so it is never an unhandled rejection, and forgets the setup so the next
433
+ // connect tries again rather than rethrowing a failure that may have passed.
434
+ setup.catch(forget);
435
+ return setup;
436
+ }
437
+
438
+ /** Answers the native broker's token requests with `provider` until the stream ends. */
439
+ private async answerTokens(provider: TokenProvider, timeoutMs: number, onEnd: () => void): Promise<void> {
440
+ const id = await this.streams.open("bridge.tokenRequest", ({ requestId }) => {
441
+ void (async () => {
442
+ let token: string;
443
+ try {
444
+ token = await provider();
445
+ } catch (failed) {
446
+ const message = failed instanceof Error ? failed.message : String(failed);
447
+ await this.ops.tokenReject({ requestId, message }).catch(() => undefined);
448
+ return;
449
+ }
450
+ await this.ops.tokenResolve({ requestId, token }).catch(() => undefined);
451
+ })();
452
+ }, undefined, onEnd);
453
+ try {
454
+ await this.ops.tokenSetProviderPresent({ present: true, timeoutMs });
455
+ } catch (failed) {
456
+ // A provider the broker was never told about answers nothing, so its stream is let go of.
457
+ this.streams.close(id);
458
+ throw failed;
459
+ }
460
+ }
461
+ }
462
+
463
+ class StreamHub {
464
+ private readonly handlers = new Map<string, (message: string) => void>();
465
+ private readonly early = new Map<string, string[]>();
466
+ private pending = 0;
467
+ private listening: { remove(): void } | undefined;
468
+
469
+ constructor(private readonly native: NativeBridge) {}
470
+
471
+ /**
472
+ * Subscribes and answers once the native stream is open. `onEnd` runs once the stream ends from the
473
+ * native side, by error or by completing; not when it is closed from here.
474
+ */
475
+ async open<S extends StreamId>(
476
+ stream: S,
477
+ listener: StreamListener<StreamElements[S]>,
478
+ onError?: (error: TerraError) => void,
479
+ onEnd?: () => void,
480
+ ): Promise<string> {
481
+ this.listen();
482
+ this.pending += 1;
483
+ let id: string;
484
+ try {
485
+ id = await this.native.subscribe(stream, "{}");
486
+ } catch (thrown) {
487
+ this.settled();
488
+ throw TerraError.from(thrown);
489
+ }
490
+ const handle = (message: string) => {
491
+ const delivery = decodeStreamDelivery(stream, JSON.parse(message));
492
+ switch (delivery.kind) {
493
+ case "data":
494
+ listener(delivery.element);
495
+ return;
496
+ case "error":
497
+ this.ended(id);
498
+ onError?.(new TerraError(delivery.error));
499
+ onEnd?.();
500
+ return;
501
+ case "complete":
502
+ this.ended(id);
503
+ onEnd?.();
504
+ return;
505
+ }
506
+ };
507
+ this.handlers.set(id, handle);
508
+ const held = this.early.get(id);
509
+ this.early.delete(id);
510
+ this.settled();
511
+ held?.forEach(handle);
512
+ return id;
513
+ }
514
+
515
+ private settled(): void {
516
+ this.pending -= 1;
517
+ if (this.pending === 0) this.early.clear();
518
+ this.releaseIfIdle();
519
+ }
520
+
521
+ private ended(id: string): void {
522
+ this.handlers.delete(id);
523
+ this.releaseIfIdle();
524
+ }
525
+
526
+ private releaseIfIdle(): void {
527
+ if (this.handlers.size > 0 || this.pending > 0) return;
528
+ this.listening?.remove();
529
+ this.listening = undefined;
530
+ }
531
+
532
+ /** Subscribes without waiting; the returned function cancels whether or not the stream has opened yet. */
533
+ subscribe<S extends StreamId>(
534
+ stream: S,
535
+ listener: StreamListener<StreamElements[S]>,
536
+ onError?: (error: TerraError) => void,
537
+ ): Unsubscribe {
538
+ let cancelled = false;
539
+ let opened: string | undefined;
540
+ this.open(stream, (element) => {
541
+ if (!cancelled) listener(element);
542
+ }, (error) => {
543
+ if (!cancelled) onError?.(error);
544
+ }).then(
545
+ (id) => {
546
+ opened = id;
547
+ if (cancelled) this.close(id);
548
+ },
549
+ (error: TerraError) => {
550
+ if (!cancelled) onError?.(error);
551
+ },
552
+ );
553
+ return () => {
554
+ if (cancelled) return;
555
+ cancelled = true;
556
+ if (opened !== undefined) this.close(opened);
557
+ };
558
+ }
559
+
560
+ close(id: string): void {
561
+ this.handlers.delete(id);
562
+ this.early.delete(id);
563
+ this.native.unsubscribe(id);
564
+ this.releaseIfIdle();
565
+ }
566
+
567
+ private listen(): void {
568
+ if (this.listening !== undefined) return;
569
+ this.listening = this.native.onMessage(({ subscriptionId, message }) => {
570
+ const handler = this.handlers.get(subscriptionId);
571
+ if (handler !== undefined) {
572
+ handler(message);
573
+ return;
574
+ }
575
+ if (this.pending === 0) return;
576
+ const held = this.early.get(subscriptionId) ?? [];
577
+ if (held.length < EARLY_LIMIT || isTerminal(message)) held.push(message);
578
+ this.early.set(subscriptionId, held);
579
+ });
580
+ }
581
+ }
582
+
583
+ const EARLY_LIMIT = 64;
584
+
585
+ function isTerminal(message: string): boolean {
586
+ try {
587
+ const kind = (JSON.parse(message) as { kind?: unknown }).kind;
588
+ return kind === "error" || kind === "complete";
589
+ } catch {
590
+ return false;
591
+ }
592
+ }