craft-native 0.0.245 → 0.0.247
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/dist/android/src/index.d.ts +23 -0
- package/dist/android/src/index.js +15 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +136 -1
- package/dist/android/templates/CraftHeartRate.kt.template +938 -0
- package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +34 -1
- package/dist/android/templates/CraftNativeServices.kt.template +34 -1
- package/dist/android/templates/CraftNativeShell.kt.template +20 -6
- package/dist/api/mobile.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +24 -1
- package/dist/ios/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +4 -0
- package/dist/ios/templates/CraftHybrid.swift +151 -14
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -6
- package/dist/ios/templates/CraftNativeScreen.swift +353 -68
- package/dist/ios/templates/CraftWebAudio.swift +21 -13
- package/dist/mobile-testing.js +19 -1
- package/dist/testing/media-cache/media-cache.kt +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.io.IOException
|
|
6
|
+
import java.nio.file.Files
|
|
7
|
+
import java.nio.file.StandardCopyOption
|
|
8
|
+
import java.security.MessageDigest
|
|
9
|
+
import java.util.UUID
|
|
10
|
+
import kotlin.math.PI
|
|
11
|
+
import kotlin.math.min
|
|
12
|
+
import kotlin.math.roundToInt
|
|
13
|
+
import kotlin.math.sin
|
|
14
|
+
import kotlin.random.Random
|
|
15
|
+
|
|
16
|
+
// The parts of the heart-rate strap service (CraftHeartRate.kt) that need no
|
|
17
|
+
// Android: the 0x2A37 decoder, the stand-in strap's beats, when a quiet strap
|
|
18
|
+
// is asked for again, and the recording kept on disk. Plain Kotlin and
|
|
19
|
+
// java.io, so a JVM fixture runs them (src/heart-rate.test.ts) against the
|
|
20
|
+
// payloads iOS's HeartRateUnitTests use. Ported from CraftHeartRate.swift.
|
|
21
|
+
|
|
22
|
+
/** A heart-rate call that did not happen: the message a page sees, and the bridge's error code. */
|
|
23
|
+
internal class CraftHeartRateException(val code: String, message: String) : Exception(message)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One Heart Rate Measurement (characteristic 0x2A37 of the Bluetooth SIG's
|
|
27
|
+
* Heart Rate Service, 0x180D), decoded.
|
|
28
|
+
*
|
|
29
|
+
* The payload is a flags byte and then the fields the flags announce, all
|
|
30
|
+
* little-endian:
|
|
31
|
+
*
|
|
32
|
+
* bit 0 the heart rate is a uint16 (else a uint8)
|
|
33
|
+
* bits 1-2 sensor contact: bit 2 says the strap reports it at all,
|
|
34
|
+
* bit 1 that the skin is touching (a strap worn loose reads 0)
|
|
35
|
+
* bit 3 a uint16 of energy expended, kilojoules, follows the rate
|
|
36
|
+
* bit 4 uint16 RR intervals follow, as many as fit, in 1/1024 s
|
|
37
|
+
*
|
|
38
|
+
* Every strap and every watch that broadcasts its heart rate (Polar, Garmin,
|
|
39
|
+
* Wahoo, a watch with a broadcasting app) sends this one shape.
|
|
40
|
+
*/
|
|
41
|
+
internal data class CraftHeartRateMeasurement(
|
|
42
|
+
/** Beats per minute. */
|
|
43
|
+
val bpm: Int,
|
|
44
|
+
/** Whether the strap is touching skin; null when it cannot tell. */
|
|
45
|
+
val sensorContact: Boolean?,
|
|
46
|
+
/** Kilojoules since the strap was last reset; null when it does not count them. */
|
|
47
|
+
val energyExpended: Int?,
|
|
48
|
+
/** The time between beats, in milliseconds, oldest first. */
|
|
49
|
+
val rrIntervals: List<Double>,
|
|
50
|
+
) {
|
|
51
|
+
companion object {
|
|
52
|
+
fun parse(bytes: ByteArray): CraftHeartRateMeasurement? = parse(IntArray(bytes.size) { bytes[it].toInt() and 0xFF })
|
|
53
|
+
|
|
54
|
+
/** Null for a payload too short for what its flags announce. */
|
|
55
|
+
fun parse(bytes: IntArray): CraftHeartRateMeasurement? {
|
|
56
|
+
if (bytes.isEmpty()) return null
|
|
57
|
+
val flags = bytes[0] and 0xFF
|
|
58
|
+
var offset = 1
|
|
59
|
+
fun uint8(): Int? {
|
|
60
|
+
if (offset >= bytes.size) return null
|
|
61
|
+
return (bytes[offset++] and 0xFF)
|
|
62
|
+
}
|
|
63
|
+
fun uint16(): Int? {
|
|
64
|
+
if (offset + 1 >= bytes.size) return null
|
|
65
|
+
val value = (bytes[offset] and 0xFF) or ((bytes[offset + 1] and 0xFF) shl 8)
|
|
66
|
+
offset += 2
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
val bpm = (if ((flags and 0x01) != 0) uint16() else uint8()) ?: return null
|
|
70
|
+
val contact: Boolean? = if ((flags and 0x04) != 0) (flags and 0x02) != 0 else null
|
|
71
|
+
var energy: Int? = null
|
|
72
|
+
if ((flags and 0x08) != 0) energy = uint16() ?: return null
|
|
73
|
+
val intervals = mutableListOf<Double>()
|
|
74
|
+
if ((flags and 0x10) != 0) {
|
|
75
|
+
while (true) intervals.add((uint16() ?: break) * 1000.0 / 1024)
|
|
76
|
+
}
|
|
77
|
+
return CraftHeartRateMeasurement(bpm, contact, energy, intervals)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The payload a strap would send for these values: the emulator's
|
|
82
|
+
* stand-in speaks it, so its readings take the same path as a strap's.
|
|
83
|
+
*/
|
|
84
|
+
fun encode(bpm: Int, sensorContact: Boolean? = true, energyExpended: Int? = null, rrIntervals: List<Double> = emptyList()): ByteArray {
|
|
85
|
+
var flags = 0
|
|
86
|
+
val body = mutableListOf<Int>()
|
|
87
|
+
fun append16(value: Int) { body.add(value and 0xFF); body.add((value shr 8) and 0xFF) }
|
|
88
|
+
if (bpm > 255) {
|
|
89
|
+
flags = flags or 0x01
|
|
90
|
+
append16(bpm)
|
|
91
|
+
} else body.add(maxOf(0, bpm))
|
|
92
|
+
if (sensorContact != null) flags = flags or 0x04 or (if (sensorContact) 0x02 else 0)
|
|
93
|
+
if (energyExpended != null) {
|
|
94
|
+
flags = flags or 0x08
|
|
95
|
+
append16(energyExpended)
|
|
96
|
+
}
|
|
97
|
+
if (rrIntervals.isNotEmpty()) {
|
|
98
|
+
flags = flags or 0x10
|
|
99
|
+
for (interval in rrIntervals) append16((interval * 1024 / 1000).roundToInt())
|
|
100
|
+
}
|
|
101
|
+
return (listOf(flags) + body).map { it.toByte() }.toByteArray()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The stand-in strap's beats from `start` (ms) until `end`, each interval in
|
|
108
|
+
* ms: the rate's own interval, plus respiratory sinus arrhythmia (beats come
|
|
109
|
+
* closer on the in-breath and spread on the out-breath over a ten-second
|
|
110
|
+
* breath, ±4% of the interval: ±40 ms at 60 bpm) and a few ms of noise, so a
|
|
111
|
+
* heart-rate variability worked out from them means something. Answers the
|
|
112
|
+
* intervals and where the last beat fell.
|
|
113
|
+
*/
|
|
114
|
+
internal fun craftHeartRateSimulatedIntervals(
|
|
115
|
+
rate: Double,
|
|
116
|
+
start: Double,
|
|
117
|
+
end: Double,
|
|
118
|
+
noise: () -> Double = { Random.nextDouble(-4.0, 4.0) },
|
|
119
|
+
): Pair<List<Double>, Double> {
|
|
120
|
+
val base = 60_000 / rate
|
|
121
|
+
var at = start
|
|
122
|
+
val intervals = mutableListOf<Double>()
|
|
123
|
+
while (at < end) {
|
|
124
|
+
val breath = sin(2 * PI * at / 10_000)
|
|
125
|
+
val interval = base * (1 - 0.04 * breath) + noise()
|
|
126
|
+
at += interval
|
|
127
|
+
intervals.add(interval)
|
|
128
|
+
}
|
|
129
|
+
return intervals to at
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a link that is still wanted has gone quiet long enough to rebuild
|
|
134
|
+
* its subscription: a connected strap that sent nothing for 20 s (a
|
|
135
|
+
* broadcasting watch can drop its notifications and keep the link), or a link
|
|
136
|
+
* that came up but never subscribed for 30 s. A pending connection is left
|
|
137
|
+
* pending: the strap comes back when it is worn.
|
|
138
|
+
*/
|
|
139
|
+
internal fun craftHeartRateShouldRestart(connection: String, linkConnected: Boolean, quietMs: Long): Boolean =
|
|
140
|
+
(connection == "connected" && quietMs > 20_000) || (connection != "connected" && linkConnected && quietMs > 30_000)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How long to wait before asking for a dropped strap again: 1, 2, 4, 8, 16
|
|
144
|
+
* and then every 30 seconds. The ask itself never times out (an automatic
|
|
145
|
+
* GATT connection waits for the strap); this only spaces out the stack's
|
|
146
|
+
* own failures (status 133 and friends) so they do not spin.
|
|
147
|
+
*/
|
|
148
|
+
internal fun craftHeartRateReconnectDelayMs(attempt: Int): Long = min(30_000L, 1_000L shl attempt.coerceIn(0, 5))
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the person has let the app use Bluetooth, in the iOS names: asked
|
|
152
|
+
* and refused is `denied`, never asked `notDetermined`.
|
|
153
|
+
*/
|
|
154
|
+
internal fun craftHeartRateAuthorizationName(granted: Boolean, asked: Boolean): String = when {
|
|
155
|
+
granted -> "authorized"
|
|
156
|
+
asked -> "denied"
|
|
157
|
+
else -> "notDetermined"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The radio in the iOS names. `adapterState` is BluetoothAdapter's: 10 off,
|
|
162
|
+
* 11 turning on, 12 on, 13 turning off. Reading it asks for nothing.
|
|
163
|
+
*/
|
|
164
|
+
internal fun craftHeartRateBluetoothName(hasAdapter: Boolean, adapterState: Int?, authorization: String): String = when {
|
|
165
|
+
!hasAdapter -> "unsupported"
|
|
166
|
+
authorization == "denied" -> "unauthorized"
|
|
167
|
+
adapterState == 12 -> "poweredOn"
|
|
168
|
+
adapterState == 10 -> "poweredOff"
|
|
169
|
+
adapterState == 11 || adapterState == 13 -> "resetting"
|
|
170
|
+
else -> "unknown"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** JSON text in and out; org.json on a phone, a small codec in the JVM fixture. */
|
|
174
|
+
internal interface CraftHeartRateJson {
|
|
175
|
+
/** Maps, lists, strings, numbers, booleans and null. */
|
|
176
|
+
fun encode(value: Any?): String
|
|
177
|
+
fun decode(text: String): Any?
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Write to a temporary file beside `destination`, sync it, then rename it into place. */
|
|
181
|
+
internal fun craftHeartRateWriteAtomically(data: ByteArray, destination: File) {
|
|
182
|
+
destination.parentFile?.mkdirs()
|
|
183
|
+
val temporary = File(destination.parentFile, ".craft-atomic-" + UUID.randomUUID())
|
|
184
|
+
try {
|
|
185
|
+
FileOutputStream(temporary).use { stream -> stream.write(data); stream.fd.sync() }
|
|
186
|
+
Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
|
187
|
+
} finally {
|
|
188
|
+
temporary.delete()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The recording a workout keeps natively, on disk in `directory`, exactly as
|
|
194
|
+
* CraftHeartRate.swift keeps it: its state in
|
|
195
|
+
* `craft-heart-rate-recording-state.json`, one sample a line in
|
|
196
|
+
* `craft-heart-rate-recording.jsonl`, a completed recording archived under
|
|
197
|
+
* the SHA-256 of its ID when the next one starts, and an acknowledgement
|
|
198
|
+
* tombstone so an acknowledged recording never comes back. A page reload, a
|
|
199
|
+
* screen change or the app being closed loses nothing: the next call reads
|
|
200
|
+
* it all back.
|
|
201
|
+
*
|
|
202
|
+
* Main thread only, like the service that owns it.
|
|
203
|
+
*/
|
|
204
|
+
internal class CraftHeartRateRecording(
|
|
205
|
+
private val directory: File,
|
|
206
|
+
private val json: CraftHeartRateJson,
|
|
207
|
+
private val clock: () -> Long = System::currentTimeMillis,
|
|
208
|
+
private val archiveWriter: (ByteArray, File) -> Unit = ::craftHeartRateWriteAtomically,
|
|
209
|
+
) {
|
|
210
|
+
companion object {
|
|
211
|
+
/** Fourteen hours of one reading a second: more is not one workout. */
|
|
212
|
+
const val MAX_SAMPLES = 50_000
|
|
213
|
+
val METHODS = setOf("startRecording", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording")
|
|
214
|
+
private const val KIND = "heart-rate"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var active = false
|
|
218
|
+
private set
|
|
219
|
+
var paused = false
|
|
220
|
+
private set
|
|
221
|
+
var id: String? = null
|
|
222
|
+
private set
|
|
223
|
+
private var startedAt: Long? = null
|
|
224
|
+
private var count = 0
|
|
225
|
+
private var restored = false
|
|
226
|
+
|
|
227
|
+
private val stateFile get() = File(directory, "craft-heart-rate-recording-state.json")
|
|
228
|
+
private val samplesFile get() = File(directory, "craft-heart-rate-recording.jsonl")
|
|
229
|
+
|
|
230
|
+
private fun storage(message: String) = CraftHeartRateException("RECORDING_STORAGE_ERROR", message)
|
|
231
|
+
private fun invalid(message: String) = CraftHeartRateException("INVALID_ARGUMENT", message)
|
|
232
|
+
|
|
233
|
+
/** Anything but our own refusal is the disk's: RECORDING_STORAGE_ERROR, with `message`. */
|
|
234
|
+
private inline fun <T> guarded(message: String, run: () -> T): T = try {
|
|
235
|
+
run()
|
|
236
|
+
} catch (error: CraftHeartRateException) {
|
|
237
|
+
throw error
|
|
238
|
+
} catch (error: Exception) {
|
|
239
|
+
throw storage(message)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// MARK: Archive (CraftRecordingArchive.swift, kind "heart-rate")
|
|
243
|
+
|
|
244
|
+
private fun archive(id: String, acknowledged: Boolean = false): File {
|
|
245
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
|
246
|
+
return File(directory, "craft-$KIND-${if (acknowledged) "ack" else "completed"}-$digest.json")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private fun isAcknowledged(id: String): Boolean {
|
|
250
|
+
val marker = archive(id, acknowledged = true)
|
|
251
|
+
if (!marker.exists()) return false
|
|
252
|
+
if (!marker.readBytes().contentEquals(id.toByteArray(Charsets.UTF_8))) throw storage("Invalid recording acknowledgement")
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun acknowledgeArchive(id: String) {
|
|
257
|
+
if (!isAcknowledged(id)) craftHeartRateWriteAtomically(id.toByteArray(Charsets.UTF_8), archive(id, acknowledged = true))
|
|
258
|
+
val completed = archive(id)
|
|
259
|
+
if (completed.exists() && !completed.delete()) throw IOException("Unable to remove the completed recording")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun readArchive(id: String): MutableMap<String, Any?>? {
|
|
263
|
+
if (isAcknowledged(id)) {
|
|
264
|
+
acknowledgeArchive(id)
|
|
265
|
+
throw CraftHeartRateException("RECORDING_NOT_FOUND", "This recording has been acknowledged")
|
|
266
|
+
}
|
|
267
|
+
val source = archive(id)
|
|
268
|
+
if (!source.exists()) return null
|
|
269
|
+
@Suppress("UNCHECKED_CAST")
|
|
270
|
+
val value = json.decode(String(source.readBytes(), Charsets.UTF_8)) as? Map<String, Any?>
|
|
271
|
+
if (value == null || value["id"] != id || value["active"] != false || value["samples"] !is List<*>)
|
|
272
|
+
throw storage("Invalid completed recording archive")
|
|
273
|
+
return value.toMutableMap()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private fun saveArchive(state: Map<String, Any?>, samples: List<Map<String, Any?>>) {
|
|
277
|
+
val id = state["id"] as? String ?: return
|
|
278
|
+
if (isAcknowledged(id)) { acknowledgeArchive(id); return }
|
|
279
|
+
if (readArchive(id) != null) return
|
|
280
|
+
val completed = state.toMutableMap()
|
|
281
|
+
completed["active"] = false
|
|
282
|
+
completed["paused"] = false
|
|
283
|
+
completed["sampleCount"] = samples.size
|
|
284
|
+
completed["samples"] = samples
|
|
285
|
+
archiveWriter(json.encode(completed).toByteArray(Charsets.UTF_8), archive(id))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun startId(options: Map<String, Any?>): String {
|
|
289
|
+
val next = if (options.containsKey("recordingId")) {
|
|
290
|
+
val requested = options["recordingId"] as? String
|
|
291
|
+
if (requested == null || requested.isBlank()) throw invalid("recordingId must be a nonempty recording ID")
|
|
292
|
+
requested
|
|
293
|
+
} else UUID.randomUUID().toString()
|
|
294
|
+
if (next == id || isAcknowledged(next) || archive(next).exists())
|
|
295
|
+
throw CraftHeartRateException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
|
|
296
|
+
return next
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// MARK: State
|
|
300
|
+
|
|
301
|
+
/** A recording that was running when the app last closed carries on. */
|
|
302
|
+
fun restore() {
|
|
303
|
+
if (restored) return
|
|
304
|
+
guarded("Unable to restore the recording") {
|
|
305
|
+
if (!stateFile.exists()) {
|
|
306
|
+
if (samplesFile.exists() && samplesFile.length() > 0) throw storage("Recording metadata is missing")
|
|
307
|
+
restored = true
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
@Suppress("UNCHECKED_CAST")
|
|
311
|
+
val saved = json.decode(stateFile.readText()) as? Map<String, Any?> ?: throw storage("Invalid recording metadata")
|
|
312
|
+
val wasActive = saved["active"] as? Boolean ?: throw storage("Invalid recording metadata")
|
|
313
|
+
active = wasActive
|
|
314
|
+
id = saved["id"] as? String
|
|
315
|
+
startedAt = (saved["startedAt"] as? Number)?.toLong()
|
|
316
|
+
paused = saved["paused"] as? Boolean ?: false
|
|
317
|
+
val current = id
|
|
318
|
+
if (current != null && isAcknowledged(current)) {
|
|
319
|
+
acknowledgeArchive(current)
|
|
320
|
+
if (samplesFile.exists() && !samplesFile.delete()) throw IOException("Unable to remove acknowledged samples")
|
|
321
|
+
id = null
|
|
322
|
+
startedAt = null
|
|
323
|
+
active = false
|
|
324
|
+
paused = false
|
|
325
|
+
persist()
|
|
326
|
+
}
|
|
327
|
+
count = samples(null).size
|
|
328
|
+
restored = true
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun persist() {
|
|
333
|
+
val saved = mapOf("active" to active, "paused" to paused, "id" to id, "startedAt" to startedAt)
|
|
334
|
+
craftHeartRateWriteAtomically(json.encode(saved).toByteArray(Charsets.UTF_8), stateFile)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
fun summary(withCount: Boolean): MutableMap<String, Any?> {
|
|
338
|
+
val summary = mutableMapOf<String, Any?>("id" to id, "active" to active, "paused" to paused, "startedAt" to startedAt)
|
|
339
|
+
if (withCount) summary["sampleCount"] = count
|
|
340
|
+
return summary
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The recording's samples, oldest first; only those after `since` (ms) when given. */
|
|
344
|
+
private fun samples(since: Double?): List<Map<String, Any?>> {
|
|
345
|
+
if (id == null) return emptyList()
|
|
346
|
+
return samplesFile.readText().split("\n").mapNotNull { line ->
|
|
347
|
+
if (line.isBlank()) return@mapNotNull null
|
|
348
|
+
@Suppress("UNCHECKED_CAST")
|
|
349
|
+
val sample = runCatching { json.decode(line) as? Map<String, Any?> }.getOrNull() ?: return@mapNotNull null
|
|
350
|
+
if (since != null && ((sample["timestamp"] as? Number)?.toDouble() ?: 0.0) <= since) return@mapNotNull null
|
|
351
|
+
sample
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Checked on the same main-thread turn as the read or change it guards. */
|
|
356
|
+
private fun requireOwner(options: Map<String, Any?>) {
|
|
357
|
+
if (!options.containsKey("expectedId")) return
|
|
358
|
+
val expected = options["expectedId"] as? String
|
|
359
|
+
if (expected == null || expected.isBlank()) throw invalid("expectedId must be a nonempty recording ID")
|
|
360
|
+
if (id != expected) throw CraftHeartRateException("RECORDING_CONFLICT", "The active heart-rate recording has changed")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// MARK: Calls
|
|
364
|
+
|
|
365
|
+
/** One recording call from a page or a native screen, by method name (`METHODS`). */
|
|
366
|
+
fun perform(method: String, options: Map<String, Any?>): Any? {
|
|
367
|
+
if (method == "acknowledgeRecording" && !options.containsKey("expectedId")) throw invalid("Acknowledgement needs expectedId")
|
|
368
|
+
restore()
|
|
369
|
+
if (method in setOf("getRecordingState", "readRecording", "stopRecording", "acknowledgeRecording") && options.containsKey("expectedId")) {
|
|
370
|
+
val expected = options["expectedId"] as? String
|
|
371
|
+
if (expected == null || expected.isBlank()) throw invalid("expectedId must be a nonempty recording ID")
|
|
372
|
+
val answer = guarded("Unable to persist the recording") { answerFor(method, expected, options) }
|
|
373
|
+
if (answer != null) return answer
|
|
374
|
+
requireOwner(options)
|
|
375
|
+
}
|
|
376
|
+
if (method in setOf("pauseRecording", "resumeRecording", "stopRecording", "readRecording")) requireOwner(options)
|
|
377
|
+
return when (method) {
|
|
378
|
+
"startRecording" -> start(options)
|
|
379
|
+
"pauseRecording" -> setPaused(true)
|
|
380
|
+
"resumeRecording" -> setPaused(false)
|
|
381
|
+
"stopRecording" -> stop()
|
|
382
|
+
"getRecordingState" -> summary(true)
|
|
383
|
+
"readRecording" -> guarded("Unable to read the recording") { samples((options["since"] as? Number)?.toDouble()) }
|
|
384
|
+
else -> throw CraftHeartRateException("UNKNOWN_ACTION", "Unsupported HeartRate method")
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The answer a call naming a recording gets from its archive or acknowledgement; null to carry on. */
|
|
389
|
+
private fun answerFor(method: String, expected: String, options: Map<String, Any?>): Any? {
|
|
390
|
+
if (method == "acknowledgeRecording") {
|
|
391
|
+
if (id == expected && active) throw CraftHeartRateException("RECORDING_ALREADY_ACTIVE", "The recording is still active")
|
|
392
|
+
val known = id == expected || isAcknowledged(expected) || readArchive(expected) != null
|
|
393
|
+
if (!known) throw CraftHeartRateException("RECORDING_CONFLICT", "Unknown recording ID")
|
|
394
|
+
acknowledgeArchive(expected)
|
|
395
|
+
if (id == expected) {
|
|
396
|
+
if (samplesFile.exists() && !samplesFile.delete()) throw IOException("Unable to remove recording samples")
|
|
397
|
+
id = null
|
|
398
|
+
startedAt = null
|
|
399
|
+
count = 0
|
|
400
|
+
persist()
|
|
401
|
+
}
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
val archived = readArchive(expected) ?: return null
|
|
405
|
+
return when (method) {
|
|
406
|
+
"readRecording" -> {
|
|
407
|
+
val since = (options["since"] as? Number)?.toDouble()
|
|
408
|
+
(archived["samples"] as? List<*>).orEmpty().filter { sample ->
|
|
409
|
+
since == null || (((sample as? Map<*, *>)?.get("timestamp") as? Number)?.toDouble() ?: 0.0) > since
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
"getRecordingState" -> archived.apply { remove("samples") }
|
|
413
|
+
else -> archived
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private fun start(options: Map<String, Any?>): Map<String, Any?> {
|
|
418
|
+
if (active) throw CraftHeartRateException("RECORDING_ALREADY_ACTIVE", "A heart-rate recording is already active")
|
|
419
|
+
val next = guarded("Unable to create the heart-rate recording") {
|
|
420
|
+
val next = startId(options)
|
|
421
|
+
saveArchive(summary(true), samples(null))
|
|
422
|
+
directory.mkdirs()
|
|
423
|
+
craftHeartRateWriteAtomically(ByteArray(0), samplesFile)
|
|
424
|
+
next
|
|
425
|
+
}
|
|
426
|
+
val previous = listOf(active, id, startedAt, paused, count)
|
|
427
|
+
active = true
|
|
428
|
+
id = next
|
|
429
|
+
startedAt = clock()
|
|
430
|
+
paused = false
|
|
431
|
+
count = 0
|
|
432
|
+
try {
|
|
433
|
+
persist()
|
|
434
|
+
} catch (error: Exception) {
|
|
435
|
+
active = previous[0] as Boolean
|
|
436
|
+
id = previous[1] as String?
|
|
437
|
+
startedAt = previous[2] as Long?
|
|
438
|
+
paused = previous[3] as Boolean
|
|
439
|
+
count = previous[4] as Int
|
|
440
|
+
throw storage("Unable to persist the recording")
|
|
441
|
+
}
|
|
442
|
+
return summary(true)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun setPaused(value: Boolean): Map<String, Any?> {
|
|
446
|
+
if (!active) throw CraftHeartRateException("NO_ACTIVE_RECORDING", "No active heart-rate recording")
|
|
447
|
+
val previous = paused
|
|
448
|
+
paused = value
|
|
449
|
+
try {
|
|
450
|
+
persist()
|
|
451
|
+
} catch (error: Exception) {
|
|
452
|
+
paused = previous
|
|
453
|
+
throw storage("Unable to persist the recording")
|
|
454
|
+
}
|
|
455
|
+
return summary(true)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private fun stop(): Map<String, Any?> {
|
|
459
|
+
val kept = guarded("Unable to persist the recording") { samples(null) }
|
|
460
|
+
val wasActive = active
|
|
461
|
+
val wasPaused = paused
|
|
462
|
+
active = false
|
|
463
|
+
paused = false
|
|
464
|
+
try {
|
|
465
|
+
persist()
|
|
466
|
+
} catch (error: Exception) {
|
|
467
|
+
active = wasActive
|
|
468
|
+
paused = wasPaused
|
|
469
|
+
throw storage("Unable to persist the recording")
|
|
470
|
+
}
|
|
471
|
+
return summary(true).apply { put("samples", kept) }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Keeps one reading while the recording runs and is not paused; false when it was not kept. */
|
|
475
|
+
fun append(sample: Map<String, Any?>): Boolean {
|
|
476
|
+
if (!active || paused || count >= MAX_SAMPLES) return false
|
|
477
|
+
return try {
|
|
478
|
+
FileOutputStream(samplesFile, true).use { it.write((json.encode(sample) + "\n").toByteArray(Charsets.UTF_8)) }
|
|
479
|
+
count += 1
|
|
480
|
+
true
|
|
481
|
+
} catch (error: Exception) {
|
|
482
|
+
false
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -71,13 +71,46 @@ internal object CraftNativeBundle {
|
|
|
71
71
|
private fun read(context: Context, asset: String): String? = sources.get(asset)
|
|
72
72
|
?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A route's file runs on the runtime stx writes beside it
|
|
76
|
+
* (`native-screens/__runtime.js`), so the two go to the isolate together,
|
|
77
|
+
* runtime first, then the route's messages. A one-file bundle carries its
|
|
78
|
+
* runtime itself.
|
|
79
|
+
*/
|
|
74
80
|
fun source(context: Context, route: String?): String? {
|
|
75
81
|
if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
|
|
76
|
-
read(context, "native-screens/$route.js")?.let {
|
|
82
|
+
read(context, "native-screens/$route.js")?.let { own ->
|
|
83
|
+
val runtime = read(context, "native-screens/__runtime.js") ?: return own
|
|
84
|
+
val messages = messages(context, route)
|
|
85
|
+
return runtime + "\n;\n" + (if (messages != null) messages + "\n;\n" else "") + own
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
return read(context, "native-screen.js")
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The messages of the areas a route uses (stx's `messages`:
|
|
93
|
+
* `native-screens/__messages/routes.json` names them), in every language.
|
|
94
|
+
* On iOS a screen reads one language's area the first time it looks
|
|
95
|
+
* inside it; an isolate cannot call back into the app and wait for an
|
|
96
|
+
* answer, so here they are evaluated ahead of the route, as
|
|
97
|
+
* `__stxNativeMessages`, which the runtime reads first.
|
|
98
|
+
*/
|
|
99
|
+
private fun messages(context: Context, route: String): String? {
|
|
100
|
+
val listing = read(context, "native-screens/__messages/routes.json") ?: return null
|
|
101
|
+
val areas = runCatching { org.json.JSONObject(listing).optJSONArray(route) }.getOrNull() ?: return null
|
|
102
|
+
if (areas.length() == 0) return null
|
|
103
|
+
val languages = context.assets.list("native-screens/__messages")?.filter { !it.contains('.') }?.sorted() ?: return null
|
|
104
|
+
val tables = languages.joinToString(",") { language ->
|
|
105
|
+
val entries = (0 until areas.length()).mapNotNull { index ->
|
|
106
|
+
val area = areas.optString(index)
|
|
107
|
+
read(context, "native-screens/__messages/$language/$area.json")?.let { "${org.json.JSONObject.quote(area)}:$it" }
|
|
108
|
+
}
|
|
109
|
+
"${org.json.JSONObject.quote(language)}:{${entries.joinToString(",")}}"
|
|
110
|
+
}
|
|
111
|
+
return "globalThis.__stxNativeMessages={$tables};"
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
fun isAvailable(context: Context): Boolean =
|
|
82
115
|
runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
|
|
83
116
|
|| (context.assets.list("native-screens")?.isNotEmpty() == true)
|
|
@@ -59,6 +59,7 @@ internal class CraftNativeServices(
|
|
|
59
59
|
private val activity: () -> Activity?,
|
|
60
60
|
) {
|
|
61
61
|
private val main = Handler(Looper.getMainLooper())
|
|
62
|
+
private val heartRateEnabled = config.optBoolean("enableHeartRate")
|
|
62
63
|
private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
|
|
63
64
|
CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
|
|
64
65
|
} else null
|
|
@@ -78,6 +79,14 @@ internal class CraftNativeServices(
|
|
|
78
79
|
CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id)))
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
if (heartRateEnabled) {
|
|
83
|
+
// The strap's readings and connection changes reach every screen
|
|
84
|
+
// and the app script as `craftHeartRate`, as on iOS. One sink for
|
|
85
|
+
// the process: each host sets the same one.
|
|
86
|
+
CraftHeartRate.get(context).sinks["native"] = { detail ->
|
|
87
|
+
CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftHeartRate").put("detail", detail))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
|
|
@@ -111,13 +120,16 @@ internal class CraftNativeServices(
|
|
|
111
120
|
if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
|
|
112
121
|
}
|
|
113
122
|
if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
|
|
123
|
+
// A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
|
|
124
|
+
// service: one connection and one recording for every screen.
|
|
125
|
+
if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))
|
|
114
126
|
// craft.crashReporter: the app's crash and error reporter (bughq).
|
|
115
127
|
if (CraftCrashReporter.enabled) add("crashReporter")
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
fun handles(module: String): Boolean = module in setOf(
|
|
119
131
|
"Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
|
|
120
|
-
"Location", "Media", "CrashReporter",
|
|
132
|
+
"Location", "Media", "CrashReporter", "HeartRate",
|
|
121
133
|
)
|
|
122
134
|
|
|
123
135
|
fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
@@ -137,6 +149,7 @@ internal class CraftNativeServices(
|
|
|
137
149
|
"StatusBar" -> statusBar(args.optString(0, "default"), done)
|
|
138
150
|
"Speech" -> speech(method, args, done)
|
|
139
151
|
"Location" -> location(method, args, done)
|
|
152
|
+
"HeartRate" -> heartRate(method, args, done)
|
|
140
153
|
"Media" -> when (method) {
|
|
141
154
|
"keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done)
|
|
142
155
|
"path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
|
|
@@ -363,6 +376,7 @@ internal class CraftNativeServices(
|
|
|
363
376
|
}
|
|
364
377
|
|
|
365
378
|
fun onRequestPermissionsResult(requestCode: Int): Boolean {
|
|
379
|
+
if (heartRateEnabled && CraftHeartRate.get(context).onPermissions(requestCode)) return true
|
|
366
380
|
if (requestCode == LOCATION_PERMISSION_REQUEST) {
|
|
367
381
|
val pending = pendingLocationPermission
|
|
368
382
|
pendingLocationPermission = null
|
|
@@ -455,6 +469,25 @@ internal class CraftNativeServices(
|
|
|
455
469
|
}
|
|
456
470
|
}
|
|
457
471
|
|
|
472
|
+
// MARK: Heart rate (CraftHeartRate.kt)
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A Bluetooth heart-rate strap, the same service as a page's
|
|
476
|
+
* `craft.heartRate`, in the iOS host's names (CraftNativeActions.swift's
|
|
477
|
+
* HeartRate module). Its events reach every screen as `craftHeartRate`.
|
|
478
|
+
*/
|
|
479
|
+
private fun heartRate(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
480
|
+
if (!heartRateEnabled) return done(failure("CAPABILITY_DISABLED", "Heart rate is disabled"))
|
|
481
|
+
CraftHeartRate.get(context).perform(method, args.optJSONObject(0) ?: JSONObject(), activity()) { result ->
|
|
482
|
+
result.fold(
|
|
483
|
+
onSuccess = { done(success(it)) },
|
|
484
|
+
onFailure = { error ->
|
|
485
|
+
done(failure((error as? CraftHeartRateException)?.code ?: "CRAFT_ERROR", error.message ?: "Heart rate failed"))
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
458
491
|
// MARK: Crash reporting (CraftCrashReporter.kt)
|
|
459
492
|
|
|
460
493
|
private fun crashReporter(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|