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.
- package/dist/android/src/index.d.ts +11 -0
- package/dist/android/src/index.js +33 -21
- 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/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
- package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
- package/dist/android/templates/CraftNativeServices.kt.template +59 -1
- package/dist/android/templates/CraftNativeShell.kt.template +54 -14
- package/dist/android/templates/MainActivityNative.kt.template +369 -69
- package/dist/android/templates/build.gradle.kts.project.template +1 -1
- package/dist/api/mobile.d.ts +39 -3
- package/dist/browser.js +9 -9
- package/dist/cli.js +90 -673
- package/dist/components/index.d.ts +50 -9
- package/dist/index.cjs +394 -438
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +446 -493
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +54 -53
- package/dist/ios/templates/CraftApp.swift +37 -2
- package/dist/ios/templates/CraftHealth.swift +217 -0
- package/dist/ios/templates/CraftHeartRate.swift +6 -3
- package/dist/ios/templates/CraftHybrid.swift +18 -19
- package/dist/ios/templates/CraftLiveActivities.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +87 -8
- package/dist/ios/templates/CraftNativeFiles.swift +15 -3
- package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
- package/dist/ios/templates/CraftNativeMutation.swift +36 -0
- package/dist/ios/templates/CraftNativeScreen.swift +813 -65
- package/dist/ios/templates/CraftNativeServices.swift +4 -2
- package/dist/ios/templates/CraftNativeUI.swift +10 -2
- package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
- package/dist/ios/templates/testing/HostApp.swift +9 -2
- package/dist/macos/templates/CraftNativeMutation.swift +36 -0
- package/dist/macos/templates/native_layout.zig +226 -2
- package/dist/mobile-testing.js +21 -3
- package/dist/mobile.js +101 -63
- 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
|
+
}
|
|
@@ -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
|
*
|