@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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/TerraReactNative.podspec +48 -0
- package/android/build.gradle +87 -0
- package/android/src/bridgeCore/kotlin/README.md +1 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeBootstrap.kt +133 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeHost.kt +32 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Conversions.kt +468 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/LauncherSlots.kt +41 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Rejections.kt +150 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +258 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +52 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +493 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +897 -0
- package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
- package/android/src/main/AndroidManifest.xml +24 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
- package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
- package/docs/availability.md +246 -0
- package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6473 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6474 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6392 -0
- package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
- package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
- package/ios/TerraBridgeCore/Conversions.swift +467 -0
- package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
- package/ios/TerraBridgeCore/Generated/BridgeOps.swift +795 -0
- package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +1317 -0
- package/ios/TerraBridgeCore/Locked.swift +15 -0
- package/ios/TerraBridgeCore/README.md +1 -0
- package/ios/TerraBridgeCore/Rejections.swift +130 -0
- package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
- package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
- package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
- package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +254 -0
- package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
- package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
- package/ios/TerraBridgeModule.mm +79 -0
- package/ios/TerraBridgeModuleImpl.swift +49 -0
- package/ios/TerraReactNative.swift +19 -0
- package/lib/EndUser.d.ts +12 -0
- package/lib/EndUser.js +13 -0
- package/lib/NativeTerraBridge.d.ts +23 -0
- package/lib/NativeTerraBridge.js +13 -0
- package/lib/Terra.d.ts +154 -0
- package/lib/Terra.js +280 -0
- package/lib/TerraError.d.ts +42 -0
- package/lib/TerraError.js +95 -0
- package/lib/createTerra.d.ts +8 -0
- package/lib/createTerra.js +19 -0
- package/lib/generated/client.d.ts +92 -0
- package/lib/generated/client.js +196 -0
- package/lib/generated/codecs.d.ts +135 -0
- package/lib/generated/codecs.js +1358 -0
- package/lib/generated/errors.d.ts +13 -0
- package/lib/generated/errors.js +68 -0
- package/lib/generated/ops.d.ts +13 -0
- package/lib/generated/ops.js +137 -0
- package/lib/generated/streams.d.ts +23 -0
- package/lib/generated/streams.js +58 -0
- package/lib/generated/types.d.ts +481 -0
- package/lib/generated/types.js +92 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.js +55 -0
- package/package.json +71 -4
- package/src/EndUser.ts +15 -0
- package/src/NativeTerraBridge.ts +27 -0
- package/src/Terra.ts +462 -0
- package/src/TerraError.ts +116 -0
- package/src/createTerra.ts +18 -0
- package/src/generated/client.ts +243 -0
- package/src/generated/codecs.ts +1287 -0
- package/src/generated/errors.ts +113 -0
- package/src/generated/ops.ts +197 -0
- package/src/generated/streams.ts +74 -0
- package/src/generated/types.ts +667 -0
- 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
|
+
}
|