craft-native 0.0.246 → 0.0.248

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 (46) hide show
  1. package/dist/android/src/index.d.ts +11 -0
  2. package/dist/android/src/index.js +33 -21
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +136 -1
  5. package/dist/android/templates/CraftHeartRate.kt.template +938 -0
  6. package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
  7. package/dist/android/templates/CraftNative.kt.template +17 -0
  8. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  11. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  12. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  13. package/dist/android/templates/CraftNativeServices.kt.template +59 -1
  14. package/dist/android/templates/CraftNativeShell.kt.template +54 -14
  15. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  16. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  17. package/dist/api/mobile.d.ts +39 -3
  18. package/dist/browser.js +9 -9
  19. package/dist/cli.js +90 -673
  20. package/dist/components/index.d.ts +50 -9
  21. package/dist/index.cjs +394 -438
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +446 -493
  25. package/dist/ios/src/index.d.ts +2 -0
  26. package/dist/ios/src/index.js +54 -53
  27. package/dist/ios/templates/CraftApp.swift +37 -2
  28. package/dist/ios/templates/CraftHealth.swift +217 -0
  29. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  30. package/dist/ios/templates/CraftHybrid.swift +18 -19
  31. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  32. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  33. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  34. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  35. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  36. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  37. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  38. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  39. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  40. package/dist/ios/templates/testing/HostApp.swift +9 -2
  41. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  42. package/dist/macos/templates/native_layout.zig +226 -2
  43. package/dist/mobile-testing.js +21 -3
  44. package/dist/mobile.js +101 -63
  45. package/dist/testing/media-cache/media-cache.kt +4 -2
  46. 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
+ }
@@ -360,6 +360,12 @@ object CraftNative {
360
360
  nodeData: FloatArray,
361
361
  treeMeta: FloatArray,
362
362
  ): FloatArray?
363
+ private external fun nativeLayoutTreeV5(
364
+ width: Float,
365
+ height: Float,
366
+ nodeData: FloatArray,
367
+ treeMeta: FloatArray,
368
+ ): FloatArray?
363
369
  private external fun nativeClipboardRead(activity: Activity): String?
364
370
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
365
371
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -660,6 +666,17 @@ object CraftNative {
660
666
  }
661
667
  }
662
668
 
669
+ /** Resolve a tree with shared auto rows and explicit equal grid rows. */
670
+ fun layoutTreeV5(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
671
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
672
+ return try {
673
+ nativeLayoutTreeV5(width, height, nodeData, treeMeta)
674
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
675
+ } catch (e: UnsatisfiedLinkError) {
676
+ null
677
+ }
678
+ }
679
+
663
680
  /**
664
681
  * The clipboard's text, or null to use the Kotlin implementation.
665
682
  *