@tryterra/react-native 0.0.0-stage → 2.1.0

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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -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 +468 -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/Rejections.kt +150 -0
  11. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
  12. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
  13. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +258 -0
  14. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +52 -0
  15. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
  16. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
  17. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +493 -0
  18. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +897 -0
  19. package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
  20. package/android/src/main/AndroidManifest.xml +24 -0
  21. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
  22. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
  23. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
  24. package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
  25. package/docs/availability.md +246 -0
  26. package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
  27. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
  28. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +81513 -0
  29. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2150 -0
  30. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2150 -0
  31. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  32. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2127 -0
  33. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  34. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
  35. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  36. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  37. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6473 -0
  38. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
  39. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +81513 -0
  40. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2150 -0
  41. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2150 -0
  42. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  43. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2127 -0
  44. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +81513 -0
  45. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2150 -0
  46. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2150 -0
  47. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  48. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2127 -0
  49. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  50. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
  51. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
  52. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  53. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  54. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6474 -0
  55. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6392 -0
  56. package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
  57. package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
  58. package/ios/TerraBridgeCore/Conversions.swift +467 -0
  59. package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
  60. package/ios/TerraBridgeCore/Generated/BridgeOps.swift +795 -0
  61. package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +1317 -0
  62. package/ios/TerraBridgeCore/Locked.swift +15 -0
  63. package/ios/TerraBridgeCore/README.md +1 -0
  64. package/ios/TerraBridgeCore/Rejections.swift +130 -0
  65. package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
  66. package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
  67. package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
  68. package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +254 -0
  69. package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
  70. package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
  71. package/ios/TerraBridgeModule.mm +79 -0
  72. package/ios/TerraBridgeModuleImpl.swift +49 -0
  73. package/ios/TerraReactNative.swift +19 -0
  74. package/lib/EndUser.d.ts +12 -0
  75. package/lib/EndUser.js +13 -0
  76. package/lib/NativeTerraBridge.d.ts +23 -0
  77. package/lib/NativeTerraBridge.js +13 -0
  78. package/lib/Terra.d.ts +154 -0
  79. package/lib/Terra.js +280 -0
  80. package/lib/TerraError.d.ts +42 -0
  81. package/lib/TerraError.js +95 -0
  82. package/lib/createTerra.d.ts +8 -0
  83. package/lib/createTerra.js +19 -0
  84. package/lib/generated/client.d.ts +92 -0
  85. package/lib/generated/client.js +196 -0
  86. package/lib/generated/codecs.d.ts +135 -0
  87. package/lib/generated/codecs.js +1358 -0
  88. package/lib/generated/errors.d.ts +13 -0
  89. package/lib/generated/errors.js +68 -0
  90. package/lib/generated/ops.d.ts +13 -0
  91. package/lib/generated/ops.js +137 -0
  92. package/lib/generated/streams.d.ts +23 -0
  93. package/lib/generated/streams.js +58 -0
  94. package/lib/generated/types.d.ts +481 -0
  95. package/lib/generated/types.js +92 -0
  96. package/lib/index.d.ts +29 -0
  97. package/lib/index.js +55 -0
  98. package/package.json +71 -4
  99. package/src/EndUser.ts +15 -0
  100. package/src/NativeTerraBridge.ts +27 -0
  101. package/src/Terra.ts +462 -0
  102. package/src/TerraError.ts +116 -0
  103. package/src/createTerra.ts +18 -0
  104. package/src/generated/client.ts +243 -0
  105. package/src/generated/codecs.ts +1287 -0
  106. package/src/generated/errors.ts +113 -0
  107. package/src/generated/ops.ts +197 -0
  108. package/src/generated/streams.ts +74 -0
  109. package/src/generated/types.ts +667 -0
  110. package/src/index.ts +52 -0
@@ -0,0 +1,77 @@
1
+ package co.tryterra.sdk.bridge
2
+
3
+ import java.util.concurrent.atomic.AtomicLong
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.CoroutineStart
6
+ import kotlinx.coroutines.Job
7
+ import kotlinx.coroutines.flow.Flow
8
+ import kotlinx.coroutines.launch
9
+
10
+ /**
11
+ * One native stream per subscriber, started on subscribe and cancelled on unsubscribe.
12
+ *
13
+ * Sharing one collection would be cheaper and wrong: `changes()` polls while it is collected, so a
14
+ * shared stream keeps polling for a subscriber that has gone away, and that costs the user's
15
+ * battery. [invalidate] is the other half of the same rule, for a host runtime that reloads.
16
+ *
17
+ * Messages arrive fully framed by the generated `BridgeStreamDispatch`, endings included, so what
18
+ * this owns is only the lifetime of each collection.
19
+ */
20
+ internal class StreamRegistry(private val scope: CoroutineScope) {
21
+
22
+ /**
23
+ * Guards [jobs], [closed] and every delivery, so subscribe, teardown and a message in flight
24
+ * are ordered by one lock: nothing is admitted or handed on once [invalidate] returns.
25
+ */
26
+ private val gate = Any()
27
+ private val jobs = HashMap<String, Job>()
28
+ private var closed: Boolean = false
29
+ private val nextId = AtomicLong(0)
30
+
31
+ /**
32
+ * Starts [messages] and sends each one to [sink].
33
+ *
34
+ * Returns the id the host cancels with. The id is minted here rather than supplied, so two
35
+ * subscriptions to the same stream cannot collide on a name the host chose.
36
+ */
37
+ fun subscribe(messages: Flow<String>, sink: (String, String) -> Unit): String {
38
+ val id = "sub-${nextId.incrementAndGet()}"
39
+ val job = scope.launch(start = CoroutineStart.LAZY) {
40
+ messages.collect { message -> deliver(id, message, sink) }
41
+ }
42
+ val admitted = synchronized(gate) {
43
+ if (!closed) jobs[id] = job
44
+ !closed
45
+ }
46
+ if (!admitted) {
47
+ job.cancel()
48
+ return id
49
+ }
50
+ job.invokeOnCompletion { synchronized(gate) { jobs.remove(id) } }
51
+ job.start()
52
+ return id
53
+ }
54
+
55
+ /** Cancels one subscription. An unknown id is ignored: a double unsubscribe is not an error. */
56
+ fun unsubscribe(id: String) {
57
+ synchronized(gate) { jobs.remove(id) }?.cancel()
58
+ }
59
+
60
+ /** Cancels every subscription, for a host runtime that is going away, and admits no later one. */
61
+ fun invalidate() {
62
+ val outstanding = synchronized(gate) {
63
+ closed = true
64
+ jobs.values.toList().also { jobs.clear() }
65
+ }
66
+ outstanding.forEach { it.cancel() }
67
+ }
68
+
69
+ /** Visible for tests: how many subscriptions are live. */
70
+ fun count(): Int = synchronized(gate) { jobs.size }
71
+
72
+ private fun deliver(id: String, message: String, sink: (String, String) -> Unit) {
73
+ synchronized(gate) {
74
+ if (!closed && id in jobs) sink(id, message)
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,86 @@
1
+ package co.tryterra.sdk.bridge
2
+
3
+ import co.tryterra.sdk.Terra
4
+ import co.tryterra.sdk.bridge.generated.BridgeDispatch
5
+ import co.tryterra.sdk.bridge.generated.BridgeStreamDispatch
6
+ import co.tryterra.sdk.bridge.generated.TerraErrorPayload
7
+ import java.time.ZoneId
8
+ import kotlinx.coroutines.CoroutineScope
9
+
10
+ /**
11
+ * The whole public surface of this module: four calls, and none of them mentions a framework.
12
+ *
13
+ * A React Native TurboModule forwards `invoke` to [dispatch] and its event emitter to [subscribe].
14
+ * A Flutter `MethodChannel` handler is `dispatch(call.method, call.arguments)` and an `EventChannel`
15
+ * wraps [subscribe]. Nothing above this line may leak below it - no `ReadableMap`, no promise type,
16
+ * no `MethodCall` - because the second wrapper is supposed to be an emitter and a shell, not a
17
+ * second bridge.
18
+ *
19
+ * One per host runtime. The client and the [tokens] broker outlive a reload and are shared; this
20
+ * object is what a runtime attaches with, and [invalidate] tears down only what it started.
21
+ *
22
+ * @param tokens the broker whose `provide` the client was built with. A different one would be a
23
+ * broker nobody asks, and every `token.*` op would answer requests that never come.
24
+ */
25
+ public class TerraBridge(
26
+ terra: Terra,
27
+ tokens: TokenBroker,
28
+ host: BridgeHost,
29
+ scope: CoroutineScope,
30
+ deviceZone: ZoneId = ZoneId.systemDefault(),
31
+ /** Receives `(subscriptionId, StreamMessage JSON)`. The framework layer forwards it to its own emitter. */
32
+ private val emit: (String, String) -> Unit,
33
+ ) {
34
+ private val session = tokens.session()
35
+ private val ops = BridgeDispatch(TerraBridgeOpsImpl(terra, host, session))
36
+ private val streams = BridgeStreamDispatch(TerraBridgeStreamsImpl(terra, session, deviceZone), Rejections::of)
37
+ private val registry = StreamRegistry(scope)
38
+
39
+ /**
40
+ * Runs one op and answers its response JSON.
41
+ *
42
+ * Throws [BridgeRejection], never a raw SDK error: the framework layer rejects a promise with a
43
+ * code and a detail bag, and asking it to translate a `TerraError` would put the error table in
44
+ * the RN module and again in the Flutter plugin.
45
+ */
46
+ public suspend fun dispatch(op: String, payload: String): String = try {
47
+ ops.dispatch(op, payload)
48
+ } catch (thrown: Throwable) {
49
+ if (thrown is kotlinx.coroutines.CancellationException) throw thrown
50
+ throw BridgeRejection(Rejections.of(thrown))
51
+ }
52
+
53
+ /**
54
+ * Starts one stream and answers the id that cancels it.
55
+ *
56
+ * Every subscription gets its own native stream. The SDK's `changes()` polls while it is
57
+ * collected, so a shared one would keep polling for a subscriber that had gone away.
58
+ */
59
+ public fun subscribe(stream: String, params: String): String {
60
+ val messages = try {
61
+ streams.open(stream, params)
62
+ } catch (thrown: Throwable) {
63
+ throw BridgeRejection(Rejections.of(thrown))
64
+ }
65
+ return registry.subscribe(messages, emit)
66
+ }
67
+
68
+ public fun unsubscribe(id: String) {
69
+ registry.unsubscribe(id)
70
+ }
71
+
72
+ /**
73
+ * For a host runtime that is going away: a JavaScript reload, or a Flutter engine detaching.
74
+ *
75
+ * The native client outlives it and must not be rebuilt, so what is torn down is everything
76
+ * this runtime started - its subscriptions and its pending token asks - and nothing a
77
+ * replacement runtime has started since.
78
+ */
79
+ public fun invalidate() {
80
+ registry.invalidate()
81
+ session.close()
82
+ }
83
+
84
+ /** What the framework layer turns into a rejected promise. */
85
+ public class BridgeRejection(public val payload: TerraErrorPayload) : Exception(payload.message)
86
+ }
@@ -0,0 +1,258 @@
1
+ package co.tryterra.sdk.bridge
2
+
3
+ import co.tryterra.sdk.RecordId
4
+ import co.tryterra.sdk.Source
5
+ import co.tryterra.sdk.Terra
6
+ import co.tryterra.sdk.bridge.generated.BoolResult
7
+ import co.tryterra.sdk.bridge.generated.BootstrapState
8
+ import co.tryterra.sdk.bridge.generated.BridgeError
9
+ import co.tryterra.sdk.bridge.generated.BridgeInstant
10
+ import co.tryterra.sdk.bridge.generated.CategoryRequest
11
+ import co.tryterra.sdk.bridge.generated.Connection
12
+ import co.tryterra.sdk.bridge.generated.ConnectRequest
13
+ import co.tryterra.sdk.bridge.generated.ConnectionList
14
+ import co.tryterra.sdk.bridge.generated.DiagnosticsState
15
+ import co.tryterra.sdk.bridge.generated.Empty
16
+ import co.tryterra.sdk.bridge.generated.HealthAccess
17
+ import co.tryterra.sdk.bridge.generated.HeartRateEntryList
18
+ import co.tryterra.sdk.bridge.generated.HeartRateList
19
+ import co.tryterra.sdk.bridge.generated.LastDeliveryResult
20
+ import co.tryterra.sdk.bridge.generated.NutritionEntryList
21
+ import co.tryterra.sdk.bridge.generated.PermissionGrant
22
+ import co.tryterra.sdk.bridge.generated.PermissionRequest
23
+ import co.tryterra.sdk.bridge.generated.RangeRequest
24
+ import co.tryterra.sdk.bridge.generated.RecordIdList
25
+ import co.tryterra.sdk.bridge.generated.RecordIdRequest
26
+ import co.tryterra.sdk.bridge.generated.SleepEntryList
27
+ import co.tryterra.sdk.bridge.generated.SleepSegmentList
28
+ import co.tryterra.sdk.bridge.generated.SourceRequest
29
+ import co.tryterra.sdk.bridge.generated.StatusResult
30
+ import co.tryterra.sdk.bridge.generated.StatusesResult
31
+ import co.tryterra.sdk.bridge.generated.StepCountList
32
+ import co.tryterra.sdk.bridge.generated.StepEntryList
33
+ import co.tryterra.sdk.bridge.generated.SupportReport
34
+ import co.tryterra.sdk.bridge.generated.TerraBridgeOps
35
+ import co.tryterra.sdk.bridge.generated.TokenProviderRequest
36
+ import co.tryterra.sdk.bridge.generated.TokenRejectRequest
37
+ import co.tryterra.sdk.bridge.generated.TokenResolveRequest
38
+ import co.tryterra.sdk.bridge.generated.WorkoutEntryList
39
+ import co.tryterra.sdk.bridge.generated.WorkoutList
40
+ import co.tryterra.sdk.bridge.generated.BodyEntryList
41
+ import co.tryterra.sdk.bridge.generated.Source as WireSource
42
+ import kotlinx.coroutines.CancellationException
43
+
44
+ /**
45
+ * The hand-written half: about forty short functions that call the SDK.
46
+ *
47
+ * It cannot be incomplete. `TerraBridgeOps` is generated from the schema, so an op added there stops
48
+ * this file compiling until someone implements it - which is the property the old wrapper's
49
+ * hand-written string switch could not have, because nothing forced the switch to be exhaustive.
50
+ *
51
+ * Everything that is not a direct call lives in `Conversions`. What is left here is the shape of
52
+ * each call and the one place the SDK's Android surface differs from the wrapper's: the activity a
53
+ * sheet needs.
54
+ */
55
+ internal class TerraBridgeOpsImpl(
56
+ private val terra: Terra,
57
+ private val host: BridgeHost,
58
+ private val tokens: TokenBroker.Session,
59
+ ) : TerraBridgeOps {
60
+
61
+ // ---- client -----------------------------------------------------------------------------
62
+
63
+ override suspend fun clientAttach(request: Empty): BootstrapState = BootstrapState(
64
+ bootstrapped = true,
65
+ devId = host.devId,
66
+ platform = host.platform,
67
+ sdkVersion = host.sdkVersion,
68
+ backgroundRegistered = host.backgroundRegistered(),
69
+ )
70
+
71
+ override suspend fun clientConnect(request: ConnectRequest): Connection {
72
+ val source = sourceOf(request.source)
73
+ val endUser = Conversions.endUser(request.endUser)
74
+ val stored = request.token?.let { terra.connect(source, endUser, it) } ?: terra.connect(source, endUser)
75
+ return Conversions.connection(stored)
76
+ }
77
+
78
+ override suspend fun clientConnections(request: Empty): ConnectionList =
79
+ ConnectionList(terra.connections().map(Conversions::connection))
80
+
81
+ override suspend fun clientDisconnect(request: SourceRequest): Empty {
82
+ terra.disconnect(sourceOf(request.source))
83
+ return Empty()
84
+ }
85
+
86
+ // ---- token ------------------------------------------------------------------------------
87
+
88
+ override suspend fun tokenSetProviderPresent(request: TokenProviderRequest): Empty {
89
+ tokens.setProviderPresent(request.present, request.timeoutMs)
90
+ return Empty()
91
+ }
92
+
93
+ override suspend fun tokenResolve(request: TokenResolveRequest): Empty {
94
+ tokens.resolve(request.requestId, request.token)
95
+ return Empty()
96
+ }
97
+
98
+ override suspend fun tokenReject(request: TokenRejectRequest): Empty {
99
+ tokens.reject(request.requestId, request.message)
100
+ return Empty()
101
+ }
102
+
103
+ // ---- permissions ------------------------------------------------------------------------
104
+
105
+ /**
106
+ * The one op that needs an activity, presented through the launcher that activity registered.
107
+ *
108
+ * A host with no registered launcher gets `bridge.no_activity`, and one whose launcher cannot
109
+ * present right now gets `bridge.permission_sheet_unavailable`, rather than a crash in the contract.
110
+ */
111
+ override suspend fun permissionsRequest(request: PermissionRequest): PermissionGrant {
112
+ val source = sourceOf(request.source)
113
+ val permissions = terra.on(source).permissions
114
+ val launcher = host.permissionLauncher(source) ?: throw BridgeError.NoActivity
115
+ val granted = try {
116
+ permissions.request(
117
+ read = request.read.map(Conversions::category).toSet(),
118
+ write = request.write.map(Conversions::category).toSet(),
119
+ workouts = request.workouts.map(Conversions::scope).toSet(),
120
+ launcher = launcher,
121
+ )
122
+ } catch (cancelled: CancellationException) {
123
+ // CancellationException is an IllegalStateException.
124
+ throw cancelled
125
+ } catch (refused: IllegalStateException) {
126
+ // The SDK's refusal of a launcher whose sheet is still up or that was unregistered,
127
+ // raised before anything is recorded.
128
+ throw BridgeError.PermissionSheetUnavailable
129
+ }
130
+ return PermissionGrant(granted = Conversions.access(granted))
131
+ }
132
+
133
+ override suspend fun permissionsStatus(request: CategoryRequest): StatusResult =
134
+ StatusResult(Conversions.permissionStatus(permissionsOf(request.source).status(Conversions.category(request.category))))
135
+
136
+ override suspend fun permissionsWriteStatus(request: CategoryRequest): StatusResult =
137
+ StatusResult(
138
+ Conversions.permissionStatus(permissionsOf(request.source).writeStatus(Conversions.category(request.category))),
139
+ )
140
+
141
+ override suspend fun permissionsRequested(request: SourceRequest): HealthAccess =
142
+ Conversions.access(permissionsOf(request.source).requested())
143
+
144
+ override suspend fun permissionsStatuses(request: SourceRequest): StatusesResult {
145
+ val statuses = permissionsOf(request.source).statuses()
146
+ .mapNotNull { (category, status) ->
147
+ // A category the wrapper does not project has no key to report it under. Dropping
148
+ // it is right: inventing one would put a name in the map that no type admits.
149
+ Conversions.category(category)?.let { it to Conversions.permissionStatus(status) }
150
+ }
151
+ .toMap()
152
+ return StatusesResult(statuses)
153
+ }
154
+
155
+ override suspend fun permissionsBackgroundReadStatus(request: SourceRequest): StatusResult =
156
+ StatusResult(Conversions.permissionStatus(permissionsOf(request.source).backgroundReadStatus()))
157
+
158
+ override suspend fun permissionsHistoryReadStatus(request: SourceRequest): StatusResult =
159
+ StatusResult(Conversions.permissionStatus(permissionsOf(request.source).historyReadStatus()))
160
+
161
+ // ---- diagnostics ------------------------------------------------------------------------
162
+
163
+ override suspend fun diagnosticsState(request: SourceRequest): DiagnosticsState =
164
+ Conversions.diagnosticsState(diagnosticsOf(request.source).state())
165
+
166
+ override suspend fun diagnosticsLastDelivery(request: SourceRequest): LastDeliveryResult =
167
+ LastDeliveryResult(diagnosticsOf(request.source).lastDelivery()?.let(BridgeInstant::format))
168
+
169
+ override suspend fun diagnosticsReport(request: SourceRequest): SupportReport =
170
+ Conversions.report(diagnosticsOf(request.source).report())
171
+
172
+ override suspend fun diagnosticsSend(request: SourceRequest): Empty {
173
+ diagnosticsOf(request.source).send()
174
+ return Empty()
175
+ }
176
+
177
+ // ---- background -------------------------------------------------------------------------
178
+
179
+ override suspend fun backgroundIsRegistered(request: Empty): BoolResult =
180
+ BoolResult(host.backgroundRegistered())
181
+
182
+ override suspend fun backgroundCancel(request: Empty): BoolResult =
183
+ BoolResult(host.cancelBackgroundSync())
184
+
185
+ // ---- body -------------------------------------------------------------------------------
186
+
187
+ override suspend fun bodyHeartRate(request: RangeRequest): HeartRateList =
188
+ HeartRateList(terra.body.heartRate(Conversions.timeRange(request.range)).map(Conversions::heartRate))
189
+
190
+ override suspend fun bodyLogHeartRate(request: HeartRateEntryList): RecordIdList =
191
+ RecordIdList(terra.body.log(request.entries.map(Conversions::entry)).map { it.value })
192
+
193
+ override suspend fun bodyLog(request: BodyEntryList): RecordIdList =
194
+ RecordIdList(terra.body.log(request.entries.map(Conversions::entry)).map { it.value })
195
+
196
+ override suspend fun bodyDelete(request: RecordIdRequest): Empty {
197
+ terra.body.delete(RecordId(request.id))
198
+ return Empty()
199
+ }
200
+
201
+ // ---- daily ------------------------------------------------------------------------------
202
+
203
+ override suspend fun dailySteps(request: RangeRequest): StepCountList =
204
+ StepCountList(terra.daily.steps(Conversions.timeRange(request.range)).map(Conversions::steps))
205
+
206
+ override suspend fun dailyLog(request: StepEntryList): RecordIdList =
207
+ RecordIdList(terra.daily.log(request.entries.map(Conversions::entry)).map { it.value })
208
+
209
+ override suspend fun dailyDelete(request: RecordIdRequest): Empty {
210
+ terra.daily.delete(RecordId(request.id))
211
+ return Empty()
212
+ }
213
+
214
+ // ---- sleep ------------------------------------------------------------------------------
215
+
216
+ override suspend fun sleepSegments(request: RangeRequest): SleepSegmentList =
217
+ SleepSegmentList(terra.sleep.segments(Conversions.timeRange(request.range)).map(Conversions::segment))
218
+
219
+ override suspend fun sleepLog(request: SleepEntryList): RecordIdList =
220
+ RecordIdList(terra.sleep.log(request.entries.map(Conversions::entry)).map { it.value })
221
+
222
+ override suspend fun sleepDelete(request: RecordIdRequest): Empty {
223
+ terra.sleep.delete(RecordId(request.id))
224
+ return Empty()
225
+ }
226
+
227
+ // ---- activity ---------------------------------------------------------------------------
228
+
229
+ override suspend fun activityWorkouts(request: RangeRequest): WorkoutList =
230
+ WorkoutList(terra.activity.workouts(Conversions.timeRange(request.range)).map(Conversions::workout))
231
+
232
+ override suspend fun activityLog(request: WorkoutEntryList): RecordIdList =
233
+ RecordIdList(terra.activity.log(request.entries.map(Conversions::entry)).map { it.value })
234
+
235
+ override suspend fun activityDelete(request: RecordIdRequest): Empty {
236
+ terra.activity.delete(RecordId(request.id))
237
+ return Empty()
238
+ }
239
+
240
+ // ---- nutrition --------------------------------------------------------------------------
241
+
242
+ override suspend fun nutritionLog(request: NutritionEntryList): RecordIdList =
243
+ RecordIdList(terra.nutrition.log(request.entries.map(Conversions::entry)).map { it.value })
244
+
245
+ override suspend fun nutritionDelete(request: RecordIdRequest): Empty {
246
+ terra.nutrition.delete(RecordId(request.id))
247
+ return Empty()
248
+ }
249
+
250
+ /** Absent means the default source, which is what every other entry point on the SDK assumes. */
251
+ private fun sourceOf(wire: WireSource?): Source = wire?.let(Conversions::source) ?: Source.HEALTH
252
+
253
+ private fun permissionsOf(wire: WireSource?) = terra.on(sourceOf(wire)).permissions
254
+
255
+ /** [wire]'s own diagnostics, which read that store's permission record rather than Health Connect's. */
256
+ private fun diagnosticsOf(wire: WireSource?) = terra.on(sourceOf(wire)).diagnostics
257
+
258
+ }
@@ -0,0 +1,52 @@
1
+ package co.tryterra.sdk.bridge
2
+
3
+ import co.tryterra.sdk.DataCategory
4
+ import co.tryterra.sdk.Terra
5
+ import co.tryterra.sdk.bridge.generated.Empty
6
+ import co.tryterra.sdk.bridge.generated.HealthChange
7
+ import co.tryterra.sdk.bridge.generated.TerraBridgeStreams
8
+ import co.tryterra.sdk.bridge.generated.TerraEvent
9
+ import co.tryterra.sdk.bridge.generated.TokenRequest
10
+ import java.time.ZoneId
11
+ import kotlinx.coroutines.flow.Flow
12
+ import kotlinx.coroutines.flow.map
13
+ import kotlinx.coroutines.flow.mapNotNull
14
+
15
+ /**
16
+ * The hand-written half of the streams: which SDK flow answers each stream, as typed values.
17
+ *
18
+ * Routing, validation and the `StreamMessage` framing are generated in `BridgeStreamDispatch`, so a
19
+ * stream added to the schema stops this file compiling until it is published here.
20
+ */
21
+ internal class TerraBridgeStreamsImpl(
22
+ private val terra: Terra,
23
+ private val tokens: TokenBroker.Session,
24
+ private val deviceZone: ZoneId,
25
+ ) : TerraBridgeStreams {
26
+
27
+ override fun diagnosticsEvents(params: Empty): Flow<TerraEvent> =
28
+ terra.diagnostics.events().map(Conversions::event)
29
+
30
+ override fun bodyChanges(params: Empty): Flow<HealthChange> = changes(DataCategory.BODY)
31
+
32
+ override fun dailyChanges(params: Empty): Flow<HealthChange> = changes(DataCategory.DAILY)
33
+
34
+ override fun sleepChanges(params: Empty): Flow<HealthChange> = changes(DataCategory.SLEEP)
35
+
36
+ override fun activityChanges(params: Empty): Flow<HealthChange> = changes(DataCategory.ACTIVITY)
37
+
38
+ override fun bridgeTokenRequest(params: Empty): Flow<TokenRequest> = tokens.requests
39
+
40
+ private fun changes(category: DataCategory): Flow<HealthChange> {
41
+ val source = when (category) {
42
+ DataCategory.BODY -> terra.body.changes()
43
+ DataCategory.DAILY -> terra.daily.changes()
44
+ DataCategory.SLEEP -> terra.sleep.changes()
45
+ DataCategory.ACTIVITY -> terra.activity.changes()
46
+ // Nutrition is write-only on both platforms and has no `changes()`.
47
+ DataCategory.NUTRITION, DataCategory.MENSTRUATION ->
48
+ error("no change stream for $category")
49
+ }
50
+ return source.mapNotNull { change -> Conversions.change(change, deviceZone) }
51
+ }
52
+ }
@@ -0,0 +1,198 @@
1
+ package co.tryterra.sdk.bridge
2
+
3
+ import co.tryterra.sdk.TerraError
4
+ import co.tryterra.sdk.bridge.generated.BridgeError
5
+ import co.tryterra.sdk.bridge.generated.TokenRequest
6
+ import java.util.concurrent.ConcurrentHashMap
7
+ import java.util.concurrent.atomic.AtomicLong
8
+ import java.util.concurrent.atomic.AtomicReference
9
+ import kotlinx.coroutines.CompletableDeferred
10
+ import kotlinx.coroutines.TimeoutCancellationException
11
+ import kotlinx.coroutines.flow.Flow
12
+ import kotlinx.coroutines.flow.MutableSharedFlow
13
+ import kotlinx.coroutines.flow.MutableStateFlow
14
+ import kotlinx.coroutines.flow.merge
15
+ import kotlinx.coroutines.flow.emitAll
16
+ import kotlinx.coroutines.flow.filter
17
+ import kotlinx.coroutines.flow.filterNotNull
18
+ import kotlinx.coroutines.flow.flow
19
+ import kotlinx.coroutines.flow.map
20
+ import kotlinx.coroutines.flow.onSubscription
21
+ import kotlinx.coroutines.flow.takeWhile
22
+ import kotlinx.coroutines.withTimeout
23
+
24
+ /**
25
+ * The SDK's token provider, answered by a runtime that is not in the same process boundary.
26
+ *
27
+ * The native client is built before any JavaScript exists, because `BackgroundSync.register` needs
28
+ * a live `Terra` at process start. So the closure the SDK holds cannot be the host's - it is this
29
+ * broker's [provide], which asks whichever runtime is listening and waits. Bootstrap creates the
30
+ * broker first, builds the client with `tokenProvider = broker::provide`, and hands the same broker
31
+ * to every [TerraBridge] it attaches; a broker the client was not built with is one nobody asks.
32
+ *
33
+ * The whole reason this is safe is in the SDK: the provider is referenced on exactly one path,
34
+ * `Linker.connect`, and `connect` can only be reached from the host's own code. A background wake
35
+ * never calls it - uploads are signed by the device key, not by a token - so the broker is never
36
+ * asked a question while nobody is there to answer it.
37
+ */
38
+ public class TokenBroker {
39
+
40
+ private val current = AtomicReference<Session?>(null)
41
+ private val nextId = AtomicLong(0)
42
+
43
+ /**
44
+ * The closure handed to `Terra`.
45
+ *
46
+ * Throws [TerraError.TokenUnavailable] rather than a bridge code when nothing is listening: a
47
+ * host should not be able to tell itself apart from a native app built without a provider.
48
+ */
49
+ public suspend fun provide(): String {
50
+ val session = current.get() ?: throw TerraError.TokenUnavailable
51
+ return session.ask("tok-${nextId.incrementAndGet()}")
52
+ }
53
+
54
+ /** A runtime's own view of the broker. One per [TerraBridge], so one per runtime. */
55
+ internal fun session(): Session = Session()
56
+
57
+ /**
58
+ * Everything one runtime asked, answers and tears down.
59
+ *
60
+ * Per runtime rather than per broker, because a reload is a new runtime answering through the
61
+ * same broker: the old one's teardown has to fail its own asks and leave the new one's alone, and
62
+ * without an owner on each ask a teardown racing a replacement rejects a link the new runtime
63
+ * could have answered.
64
+ */
65
+ internal inner class Session {
66
+ private val pending = ConcurrentHashMap<String, CompletableDeferred<String>>()
67
+
68
+ /** Each new ask's id. */
69
+ private val announced = MutableSharedFlow<String>(extraBufferCapacity = ANNOUNCE_BUFFER)
70
+
71
+ /** A state rather than a message on [announced], so a full buffer cannot drop the ending. */
72
+ private val closing = MutableStateFlow(false)
73
+
74
+ @Volatile
75
+ private var open: Boolean = true
76
+
77
+ @Volatile
78
+ private var timeoutMs: Int = DEFAULT_TIMEOUT_MS
79
+
80
+ /**
81
+ * What the `bridge.tokenRequest` stream publishes for this runtime: every subscription sees
82
+ * every ask, and the first answer settles it.
83
+ *
84
+ * Broadcast rather than handed to one collector, because a runtime can hold two
85
+ * subscriptions - a reload of the screen that made the first, a second `createTerra` - and
86
+ * an ask taken by one that never answers would time the link out while the other could
87
+ * have answered. A subscription starting late is first handed the asks still waiting, so
88
+ * an ask made between setting the provider and subscribing is not lost; the replay and the
89
+ * live feed can overlap, so each subscription skips an id it has already seen. Another
90
+ * runtime's asks never reach it: they live in that runtime's own session.
91
+ */
92
+ val requests: Flow<TokenRequest> = flow {
93
+ val seen = HashSet<String>()
94
+ val asks = announced.onSubscription { for (id in pending.keys.sorted()) emit(id) }
95
+ emitAll(
96
+ merge(asks, closing.filter { it }.map { null })
97
+ .takeWhile { it != null }
98
+ .filterNotNull()
99
+ .filter { id -> seen.add(id) && pending.containsKey(id) }
100
+ .map { id -> TokenRequest(requestId = id) },
101
+ )
102
+ }
103
+
104
+ /**
105
+ * Whether this runtime answers, and how long it may take.
106
+ *
107
+ * Without this the broker cannot tell "no host is listening" from "the host is slow", and
108
+ * would spend the whole timeout on a link that could have failed immediately with the same
109
+ * code a native app with no provider gets.
110
+ */
111
+ fun setProviderPresent(present: Boolean, timeoutMs: Int) {
112
+ this.timeoutMs = timeoutMs
113
+ if (present) {
114
+ if (open) current.set(this)
115
+ } else {
116
+ current.compareAndSet(this, null)
117
+ }
118
+ }
119
+
120
+ suspend fun ask(id: String): String {
121
+ val answer = CompletableDeferred<String>()
122
+ pending[id] = answer
123
+ // Re-read after the insert. `close` between the caller's check and the insert would
124
+ // otherwise leave this ask in the map with nobody left to answer it, and the link would
125
+ // wait out its whole timeout instead of failing at once.
126
+ if (!open) {
127
+ pending.remove(id)
128
+ throw TerraError.TokenUnavailable
129
+ }
130
+ val limit = timeoutMs
131
+ return try {
132
+ // Inside the timeout: a subscription that stopped collecting can fill the feed, and
133
+ // an ask waiting for room is still an ask the host has not answered.
134
+ withTimeout(limit.toLong()) {
135
+ announced.emit(id)
136
+ answer.await()
137
+ }
138
+ } catch (timedOut: TimeoutCancellationException) {
139
+ throw TerraError.TokenProviderFailed(BridgeError.TokenTimeout(limit))
140
+ } finally {
141
+ pending.remove(id)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * An unknown or already-settled id is ignored rather than refused.
147
+ *
148
+ * A host that reloads answers a request the previous runtime made, and failing that answer
149
+ * would turn a harmless race into a failed link.
150
+ */
151
+ fun resolve(requestId: String, token: String) {
152
+ pending[requestId]?.complete(token)
153
+ }
154
+
155
+ fun reject(requestId: String, message: String) {
156
+ pending[requestId]?.completeExceptionally(TerraError.TokenProviderFailed(ProviderRefused(message)))
157
+ }
158
+
159
+ /**
160
+ * Fails this runtime's outstanding asks, for a runtime that is going away.
161
+ *
162
+ * Otherwise a pending ask holds the link open for its whole timeout and the host sees a
163
+ * connect that hangs rather than one that failed. Marked closed before draining, so an ask
164
+ * inserted after the drain finds `open` false on its re-read and removes itself.
165
+ */
166
+ fun close() {
167
+ open = false
168
+ current.compareAndSet(this, null)
169
+ closing.value = true
170
+ while (pending.isNotEmpty()) {
171
+ for (id in pending.keys.toList()) {
172
+ pending.remove(id)?.completeExceptionally(
173
+ TerraError.TokenProviderFailed(ProviderRefused("the host runtime went away")),
174
+ )
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Visible for tests: how many asks are waiting for an answer. */
180
+ fun pendingCount(): Int = pending.size
181
+ }
182
+
183
+ /** The provider's own failure, carried so that its message reaches `details.message`. */
184
+ public class ProviderRefused(message: String) : Exception(message)
185
+
186
+ public companion object {
187
+ /**
188
+ * Thirty seconds, matching the documented default.
189
+ *
190
+ * Long enough for a host to call its own backend on a poor connection, short enough that a
191
+ * host which never answers fails the link rather than appearing to hang.
192
+ */
193
+ public const val DEFAULT_TIMEOUT_MS: Int = 30_000
194
+
195
+ /** Asks a slow subscription can fall behind by; a link makes at most two. */
196
+ private const val ANNOUNCE_BUFFER = 64
197
+ }
198
+ }