@craft-native/android 0.0.436 → 0.0.443
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/index.d.ts +44 -0
- package/dist/index.js +83 -4
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/templates/AndroidManifest.xml.template +2 -1
- package/templates/CraftHealthServices.kt.template +2 -0
- package/templates/CraftNativeCapabilities.kt.template +42 -1
- package/templates/CraftNativeMotion.kt.template +269 -0
- package/templates/CraftNativeServices.kt.template +6 -1
- package/templates/CraftPassiveSteps.kt.template +159 -0
- package/templates/CraftRecordingPressure.kt.template +156 -0
- package/templates/LocationRecordingService.kt.template +77 -9
- package/templates/MainActivity.kt.template +3 -0
- package/templates/MainActivityNative.kt.template +132 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package com.craft.runtime
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.hardware.Sensor
|
|
5
|
+
import android.hardware.SensorEvent
|
|
6
|
+
import android.hardware.SensorEventListener
|
|
7
|
+
import android.hardware.SensorManager
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import android.os.PowerManager
|
|
11
|
+
import org.json.JSONArray
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
|
|
16
|
+
/** Native-only journal: JS suspension cannot drop pressure readings from a route. */
|
|
17
|
+
object CraftRecordingPressure {
|
|
18
|
+
private fun file(context: Context, id: String): File {
|
|
19
|
+
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8))
|
|
20
|
+
return File(context.filesDir, "craft-pressure-${digest.joinToString("") { "%02x".format(it) }}.jsonl")
|
|
21
|
+
}
|
|
22
|
+
fun requireAvailable(context: Context) {
|
|
23
|
+
val config = context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
|
|
24
|
+
if (!config.optBoolean("enableBarometer") || !config.optBoolean("enableBackgroundLocation"))
|
|
25
|
+
throw CraftRecordingException("CAPABILITY_DISABLED", "Recording pressure requires enableBarometer and enableBackgroundLocation")
|
|
26
|
+
val manager = context.getSystemService(SensorManager::class.java)
|
|
27
|
+
if (manager?.getDefaultSensor(Sensor.TYPE_PRESSURE, true) == null && manager?.getDefaultSensor(Sensor.TYPE_PRESSURE) == null)
|
|
28
|
+
throw CraftRecordingException("UNAVAILABLE", "Barometer is not present")
|
|
29
|
+
}
|
|
30
|
+
// Callers also hold the recording-store lock to serialize owner/ACK changes.
|
|
31
|
+
fun append(context: Context, id: String, event: JSONObject) {
|
|
32
|
+
val bytes = (event.put("recordingId", id).toString() + "\n").toByteArray(Charsets.UTF_8)
|
|
33
|
+
java.io.FileOutputStream(file(context, id), true).use { it.write(bytes); it.fd.sync() }
|
|
34
|
+
}
|
|
35
|
+
fun read(context: Context, id: String): JSONArray {
|
|
36
|
+
val result = JSONArray()
|
|
37
|
+
val source = file(context, id)
|
|
38
|
+
if (id.isEmpty() || !source.exists()) return result
|
|
39
|
+
source.forEachLine { line ->
|
|
40
|
+
if (line.isNotBlank()) {
|
|
41
|
+
val event = JSONObject(line)
|
|
42
|
+
check(event.getString("recordingId") == id) { "Invalid pressure recording owner" }
|
|
43
|
+
result.put(event)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result
|
|
47
|
+
}
|
|
48
|
+
fun delete(context: Context, id: String) {
|
|
49
|
+
val source = file(context, id)
|
|
50
|
+
if (source.exists() && !source.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged pressure recording")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
internal interface CraftRecordingPressureSource {
|
|
55
|
+
fun open(sample: (Float, Long) -> Unit): AutoCloseable
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private class AndroidRecordingPressureSource(private val context: Context) : CraftRecordingPressureSource {
|
|
59
|
+
override fun open(sample: (Float, Long) -> Unit): AutoCloseable {
|
|
60
|
+
CraftRecordingPressure.requireAvailable(context)
|
|
61
|
+
val manager = context.getSystemService(SensorManager::class.java)
|
|
62
|
+
?: throw IllegalStateException("Sensor manager unavailable")
|
|
63
|
+
val sensor = manager.getDefaultSensor(Sensor.TYPE_PRESSURE, true)
|
|
64
|
+
?: manager.getDefaultSensor(Sensor.TYPE_PRESSURE)
|
|
65
|
+
?: throw IllegalStateException("Barometer unavailable")
|
|
66
|
+
// A non-wake-up sensor cannot deliver continuously during CPU suspend.
|
|
67
|
+
// Only the explicitly requested, unpaused foreground recording owns this lock.
|
|
68
|
+
val wake = if (!sensor.isWakeUpSensor) context.getSystemService(PowerManager::class.java)
|
|
69
|
+
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "craft:recording-pressure").apply { setReferenceCounted(false) } else null
|
|
70
|
+
val listener = object : SensorEventListener {
|
|
71
|
+
override fun onSensorChanged(event: SensorEvent) { sample(event.values.firstOrNull() ?: Float.NaN, event.timestamp) }
|
|
72
|
+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
wake?.acquire()
|
|
76
|
+
check(manager.registerListener(listener, sensor, 1_000_000, Handler(Looper.getMainLooper()))) { "Pressure registration rejected" }
|
|
77
|
+
} catch (error: Exception) {
|
|
78
|
+
manager.unregisterListener(listener)
|
|
79
|
+
if (wake?.isHeld == true) wake.release()
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
return AutoCloseable {
|
|
83
|
+
try { manager.unregisterListener(listener) }
|
|
84
|
+
finally { if (wake?.isHeld == true) wake.release() }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One service-owned segment, independent of native screen and WebView lifetimes. */
|
|
90
|
+
internal class CraftRecordingPressureSession(
|
|
91
|
+
private val context: Context,
|
|
92
|
+
val id: String,
|
|
93
|
+
private val source: CraftRecordingPressureSource = AndroidRecordingPressureSource(context),
|
|
94
|
+
) {
|
|
95
|
+
val epoch = CraftLocationRecordingStore.pressureEpoch
|
|
96
|
+
private var registration: AutoCloseable? = null
|
|
97
|
+
private var closed = false
|
|
98
|
+
private var started = false
|
|
99
|
+
private var reference: Float? = null
|
|
100
|
+
private var lastTimestamp: Long? = null
|
|
101
|
+
private var segment = UUID.randomUUID().toString()
|
|
102
|
+
|
|
103
|
+
private fun ownsRecording() = epoch == CraftLocationRecordingStore.pressureEpoch && CraftLocationRecordingStore.recordingId(context) == id &&
|
|
104
|
+
CraftLocationRecordingStore.isActive(context) && !CraftLocationRecordingStore.isPaused(context) &&
|
|
105
|
+
CraftLocationRecordingStore.pressureEnabled(context)
|
|
106
|
+
|
|
107
|
+
fun start() = synchronized(CraftLocationRecordingStore) {
|
|
108
|
+
if (closed || started || !ownsRecording()) return@synchronized
|
|
109
|
+
started = true
|
|
110
|
+
try {
|
|
111
|
+
write(JSONObject().put("state", "segment").put("reason", "startedOrRecovered"))
|
|
112
|
+
val acquired = source.open(::sample)
|
|
113
|
+
if (closed) acquired.close() else registration = acquired
|
|
114
|
+
} catch (error: Exception) { fail(error) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun write(event: JSONObject) {
|
|
118
|
+
CraftRecordingPressure.append(context, id, event.put("segmentId", segment).put("timestamp", System.currentTimeMillis()))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun sample(pressure: Float, timestamp: Long) = synchronized(CraftLocationRecordingStore) {
|
|
122
|
+
if (closed || !ownsRecording()) return@synchronized
|
|
123
|
+
try {
|
|
124
|
+
require(pressure.isFinite() && pressure > 0 && timestamp >= 0) { "Invalid pressure sample" }
|
|
125
|
+
val previous = lastTimestamp
|
|
126
|
+
if (previous != null && timestamp <= previous) return@synchronized
|
|
127
|
+
if (previous != null && timestamp - previous < 1_000_000_000L) return@synchronized
|
|
128
|
+
if (previous != null && timestamp - previous > 3_000_000_000L) {
|
|
129
|
+
segment = UUID.randomUUID().toString()
|
|
130
|
+
reference = null
|
|
131
|
+
write(JSONObject().put("state", "segment").put("reason", "sampleGap").put("timestampMs", timestamp / 1_000_000.0))
|
|
132
|
+
}
|
|
133
|
+
val baseline = reference ?: pressure.also { reference = it }
|
|
134
|
+
val altitude = SensorManager.getAltitude(baseline, pressure)
|
|
135
|
+
require(altitude.isFinite()) { "Invalid pressure altitude" }
|
|
136
|
+
write(JSONObject().put("state", "sample").put("pressureHpa", pressure)
|
|
137
|
+
.put("referencePressureHpa", baseline).put("relativeAltitudeMeters", altitude)
|
|
138
|
+
.put("timestampMs", timestamp / 1_000_000.0))
|
|
139
|
+
lastTimestamp = timestamp
|
|
140
|
+
} catch (error: Exception) { fail(error) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun fail(error: Exception) {
|
|
144
|
+
close()
|
|
145
|
+
runCatching { write(JSONObject().put("state", "stopped").put("reason", "sensorOrStorageError")) }
|
|
146
|
+
CraftLocationRecordingStore.failPressure(context, id, error.message ?: "Pressure recording failed")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun close() {
|
|
150
|
+
if (closed) return
|
|
151
|
+
closed = true
|
|
152
|
+
runCatching { registration?.close() }
|
|
153
|
+
.onFailure { android.util.Log.e("CraftLocation", "Unable to unregister pressure listener", it) }
|
|
154
|
+
registration = null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -110,20 +110,21 @@ object CraftLocationRecordingStore {
|
|
|
110
110
|
if (!acknowledged(context, id)) return
|
|
111
111
|
val saved = archive(context, id)
|
|
112
112
|
if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged recording")
|
|
113
|
+
CraftRecordingPressure.delete(context, id)
|
|
113
114
|
if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged samples")
|
|
114
|
-
edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
|
|
115
|
+
edit(context) { it.remove("id").remove("startedAt").remove("barometer").remove("barometerError").putBoolean("active", false).putBoolean("paused", false) }
|
|
115
116
|
listener = null
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
@Synchronized
|
|
119
|
-
fun start(context: Context, id: String, startedAt: Long, powerMode: String = "default") {
|
|
120
|
+
fun start(context: Context, id: String, startedAt: Long, powerMode: String = "default", barometer: Boolean = false) {
|
|
120
121
|
recover(context)
|
|
121
122
|
if (isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "A location recording is already active")
|
|
122
123
|
if (id == recordingId(context) || archive(context, id).exists() || acknowledged(context, id))
|
|
123
124
|
throw CraftRecordingException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
|
|
124
125
|
archiveCompleted(context)
|
|
125
126
|
file(context).writeText("")
|
|
126
|
-
edit(context) { it.putString("id", id).putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt).putString("powerMode", powerMode).remove("powerSaver") }
|
|
127
|
+
edit(context) { it.putString("id", id).putBoolean("barometer", barometer).remove("barometerError").putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt).putString("powerMode", powerMode).remove("powerSaver") }
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
private val POWER_MODES = setOf("default", "saver", "auto")
|
|
@@ -187,7 +188,10 @@ object CraftLocationRecordingStore {
|
|
|
187
188
|
fun updatePause(context: Context, paused: Boolean, options: JSONObject?): JSONObject {
|
|
188
189
|
requireOwner(context, options)
|
|
189
190
|
if (!isActive(context)) throw CraftRecordingException("NO_ACTIVE_RECORDING", "No active location recording")
|
|
191
|
+
if (paused && !isPaused(context)) pressureEvent(context, "paused")
|
|
190
192
|
commit(context, true, paused)
|
|
193
|
+
pressureEpoch += 1
|
|
194
|
+
changed?.invoke()
|
|
191
195
|
return state(context)
|
|
192
196
|
}
|
|
193
197
|
@Synchronized fun setPaused(context: Context, paused: Boolean) { updatePause(context, paused, null) }
|
|
@@ -196,8 +200,10 @@ object CraftLocationRecordingStore {
|
|
|
196
200
|
fun finish(context: Context, options: JSONObject?): JSONObject {
|
|
197
201
|
completed(context, expected(options))?.let { return it }
|
|
198
202
|
requireOwner(context, options)
|
|
203
|
+
if (isActive(context)) pressureEvent(context, "stopped")
|
|
199
204
|
val stopped = state(context, includeLocations = true)
|
|
200
205
|
commit(context, false, false)
|
|
206
|
+
changed?.invoke()
|
|
201
207
|
listener = null
|
|
202
208
|
return stopped.put("active", false).put("paused", false).put("effectivePowerMode", "default")
|
|
203
209
|
}
|
|
@@ -213,9 +219,10 @@ object CraftLocationRecordingStore {
|
|
|
213
219
|
val marker = File(saved.path.replace("-completed-", "-ack-"))
|
|
214
220
|
if (!marker.exists()) writeAtomic(marker, id)
|
|
215
221
|
if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove completed recording")
|
|
222
|
+
CraftRecordingPressure.delete(context, id)
|
|
216
223
|
if (current) {
|
|
217
224
|
if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove recording samples")
|
|
218
|
-
edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
|
|
225
|
+
edit(context) { it.remove("id").remove("startedAt").remove("barometer").remove("barometerError").putBoolean("active", false).putBoolean("paused", false) }
|
|
219
226
|
listener = null
|
|
220
227
|
}
|
|
221
228
|
return true
|
|
@@ -236,7 +243,11 @@ object CraftLocationRecordingStore {
|
|
|
236
243
|
if (requested !is String || requested.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "recordingId must be a nonempty recording ID")
|
|
237
244
|
requested
|
|
238
245
|
} else java.util.UUID.randomUUID().toString()
|
|
239
|
-
|
|
246
|
+
if (options.has("barometer") && options.opt("barometer") !is Boolean)
|
|
247
|
+
throw CraftRecordingException("INVALID_ARGUMENT", "barometer must be boolean")
|
|
248
|
+
val pressure = options.optBoolean("barometer", false)
|
|
249
|
+
if (pressure) CraftRecordingPressure.requireAvailable(context)
|
|
250
|
+
start(context, id, System.currentTimeMillis(), requestedPowerMode(options, "default"), pressure)
|
|
240
251
|
try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", id)) }
|
|
241
252
|
catch (error: Exception) { finish(context, JSONObject().put("expectedId", id)); throw error }
|
|
242
253
|
state(context)
|
|
@@ -267,7 +278,16 @@ object CraftLocationRecordingStore {
|
|
|
267
278
|
context.startService(Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP).putExtra("recordingId", stopped.optString("id")))
|
|
268
279
|
stopped
|
|
269
280
|
}
|
|
270
|
-
"getRecordingState" ->
|
|
281
|
+
"getRecordingState" -> {
|
|
282
|
+
if (options.has("includeBarometerSamples") && options.opt("includeBarometerSamples") !is Boolean)
|
|
283
|
+
throw CraftRecordingException("INVALID_ARGUMENT", "includeBarometerSamples must be boolean")
|
|
284
|
+
state(context, options = options).also { result ->
|
|
285
|
+
if (options.optBoolean("includeBarometerSamples")) {
|
|
286
|
+
val id = result.optString("id")
|
|
287
|
+
result.put("barometerEvents", CraftRecordingPressure.read(context, id))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
271
291
|
"readRecording" -> read(context, options)
|
|
272
292
|
"acknowledgeRecording" -> acknowledge(context, options)
|
|
273
293
|
else -> throw CraftRecordingException("UNKNOWN_ACTION", "Unsupported Location method")
|
|
@@ -281,6 +301,19 @@ object CraftLocationRecordingStore {
|
|
|
281
301
|
@Synchronized fun isActive(context: Context) = prefs(context).getBoolean("active", false)
|
|
282
302
|
@Synchronized fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
|
|
283
303
|
@Synchronized fun recordingId(context: Context): String? = prefs(context).getString("id", null)
|
|
304
|
+
@Volatile internal var pressureEpoch = 0L
|
|
305
|
+
@Volatile internal var changed: (() -> Unit)? = null
|
|
306
|
+
@Synchronized internal fun pressureEnabled(context: Context) = prefs(context).getBoolean("barometer", false)
|
|
307
|
+
@Synchronized internal fun failPressure(context: Context, id: String, message: String) {
|
|
308
|
+
if (recordingId(context) != id || !isActive(context)) return
|
|
309
|
+
edit(context) { it.putBoolean("active", false).putBoolean("paused", false).putString("barometerError", message) }
|
|
310
|
+
changed?.invoke()
|
|
311
|
+
}
|
|
312
|
+
private fun pressureEvent(context: Context, reason: String) {
|
|
313
|
+
val id = recordingId(context) ?: return
|
|
314
|
+
if (pressureEnabled(context)) CraftRecordingPressure.append(context, id,
|
|
315
|
+
JSONObject().put("state", "stopped").put("reason", reason).put("timestamp", System.currentTimeMillis()))
|
|
316
|
+
}
|
|
284
317
|
@Volatile var listener: ((android.location.Location, String) -> Unit)? = null
|
|
285
318
|
@Volatile var nativeListener: ((android.location.Location, String) -> Unit)? = null
|
|
286
319
|
|
|
@@ -329,7 +362,7 @@ object CraftLocationRecordingStore {
|
|
|
329
362
|
@Synchronized
|
|
330
363
|
fun state(context: Context, includeLocations: Boolean = false, options: JSONObject? = null): JSONObject {
|
|
331
364
|
completed(context, expected(options))?.let {
|
|
332
|
-
if (!includeLocations) it.remove("locations")
|
|
365
|
+
if (!includeLocations) { it.remove("locations"); it.remove("barometerEvents") }
|
|
333
366
|
return it
|
|
334
367
|
}
|
|
335
368
|
requireOwner(context, options)
|
|
@@ -343,7 +376,12 @@ object CraftLocationRecordingStore {
|
|
|
343
376
|
put("sampleCount", locations.length())
|
|
344
377
|
put("powerMode", powerMode(context))
|
|
345
378
|
put("effectivePowerMode", if (isPowerSaver(context)) "saver" else "default")
|
|
346
|
-
|
|
379
|
+
put("barometer", pressureEnabled(context))
|
|
380
|
+
values.getString("barometerError", null)?.let { put("barometerError", it) }
|
|
381
|
+
if (includeLocations) {
|
|
382
|
+
put("locations", locations)
|
|
383
|
+
if (pressureEnabled(context)) put("barometerEvents", CraftRecordingPressure.read(context, recordingId(context) ?: ""))
|
|
384
|
+
}
|
|
347
385
|
}
|
|
348
386
|
}
|
|
349
387
|
}
|
|
@@ -355,9 +393,33 @@ class LocationRecordingService : Service() {
|
|
|
355
393
|
private var callbackSaver = false
|
|
356
394
|
private var serviceStartId = 0
|
|
357
395
|
private var powerReceiver: BroadcastReceiver? = null
|
|
396
|
+
private var pressure: CraftRecordingPressureSession? = null
|
|
397
|
+
private val mainHandler = android.os.Handler(Looper.getMainLooper())
|
|
398
|
+
private val pressureChanged: () -> Unit = {
|
|
399
|
+
mainHandler.post {
|
|
400
|
+
if (!CraftLocationRecordingStore.isActive(this)) { stopUpdates(); stopSelfResult(serviceStartId) }
|
|
401
|
+
else syncPressure()
|
|
402
|
+
}
|
|
403
|
+
Unit
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun syncPressure() = synchronized(CraftLocationRecordingStore) {
|
|
407
|
+
val id = CraftLocationRecordingStore.recordingId(this)
|
|
408
|
+
val wanted = id != null && CraftLocationRecordingStore.isActive(this) &&
|
|
409
|
+
!CraftLocationRecordingStore.isPaused(this) && CraftLocationRecordingStore.pressureEnabled(this)
|
|
410
|
+
if (wanted && pressure?.id == id && pressure?.epoch == CraftLocationRecordingStore.pressureEpoch) return@synchronized
|
|
411
|
+
pressure?.close()
|
|
412
|
+
pressure = null
|
|
413
|
+
if (wanted) {
|
|
414
|
+
val next = CraftRecordingPressureSession(this, id!!)
|
|
415
|
+
pressure = next
|
|
416
|
+
next.start()
|
|
417
|
+
}
|
|
418
|
+
}
|
|
358
419
|
|
|
359
420
|
override fun onCreate() {
|
|
360
421
|
super.onCreate()
|
|
422
|
+
CraftLocationRecordingStore.changed = pressureChanged
|
|
361
423
|
val manager = getSystemService(NotificationManager::class.java)
|
|
362
424
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
363
425
|
manager.createNotificationChannel(NotificationChannel(CHANNEL, "Activity recording", NotificationManager.IMPORTANCE_LOW))
|
|
@@ -417,6 +479,7 @@ class LocationRecordingService : Service() {
|
|
|
417
479
|
val saver = CraftLocationRecordingStore.isPowerSaver(this)
|
|
418
480
|
if (callback != null && callbackRecordingId == id && callbackSaver == saver) {
|
|
419
481
|
syncPowerReceiver()
|
|
482
|
+
syncPressure()
|
|
420
483
|
return true
|
|
421
484
|
}
|
|
422
485
|
stopUpdates()
|
|
@@ -446,6 +509,7 @@ class LocationRecordingService : Service() {
|
|
|
446
509
|
callbackRecordingId = id
|
|
447
510
|
callbackSaver = saver
|
|
448
511
|
syncPowerReceiver()
|
|
512
|
+
syncPressure()
|
|
449
513
|
return try {
|
|
450
514
|
client.requestLocationUpdates(request, nextCallback, Looper.getMainLooper())
|
|
451
515
|
.addOnFailureListener {
|
|
@@ -460,13 +524,15 @@ class LocationRecordingService : Service() {
|
|
|
460
524
|
}
|
|
461
525
|
true
|
|
462
526
|
} catch (error: Exception) {
|
|
463
|
-
|
|
527
|
+
stopUpdates()
|
|
464
528
|
runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
|
|
465
529
|
false
|
|
466
530
|
}
|
|
467
531
|
}
|
|
468
532
|
|
|
469
533
|
private fun stopUpdates() {
|
|
534
|
+
pressure?.close()
|
|
535
|
+
pressure = null
|
|
470
536
|
callback?.let { client.removeLocationUpdates(it) }
|
|
471
537
|
callback = null
|
|
472
538
|
callbackRecordingId = null
|
|
@@ -504,6 +570,8 @@ class LocationRecordingService : Service() {
|
|
|
504
570
|
}
|
|
505
571
|
|
|
506
572
|
override fun onDestroy() {
|
|
573
|
+
if (CraftLocationRecordingStore.changed === pressureChanged) CraftLocationRecordingStore.changed = null
|
|
574
|
+
mainHandler.removeCallbacksAndMessages(null)
|
|
507
575
|
stopUpdates()
|
|
508
576
|
super.onDestroy()
|
|
509
577
|
}
|
|
@@ -76,6 +76,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
76
76
|
|
|
77
77
|
@SuppressLint("SetJavaScriptEnabled")
|
|
78
78
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
79
|
+
// The activity starts in the launch theme (the splash); its own theme
|
|
80
|
+
// takes over before the first frame, on the same background colour.
|
|
81
|
+
setTheme(R.style.Theme_CraftApp)
|
|
79
82
|
applyConfiguredAppearance()
|
|
80
83
|
super.onCreate(savedInstanceState)
|
|
81
84
|
|
|
@@ -4563,6 +4563,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
4563
4563
|
val pressUnderlayShowHandlers = mutableMapOf<View, String>()
|
|
4564
4564
|
val pressUnderlayHideHandlers = mutableMapOf<View, String>()
|
|
4565
4565
|
val longPressHandlers = mutableMapOf<View, String>()
|
|
4566
|
+
val accessibilityActionHandlers = mutableMapOf<View, String>()
|
|
4567
|
+
// TalkBack actions are added by id, so removing them on a reconcile
|
|
4568
|
+
// needs the ids back; without this a renamed action stacks up beside
|
|
4569
|
+
// the old one every render.
|
|
4570
|
+
val accessibilityActionIds = mutableMapOf<View, List<Int>>()
|
|
4566
4571
|
val scrollHandlers = mutableMapOf<View, String>()
|
|
4567
4572
|
val scrollBeginHandlers = mutableMapOf<View, String>()
|
|
4568
4573
|
val scrollEndHandlers = mutableMapOf<View, String>()
|
|
@@ -4596,6 +4601,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4596
4601
|
val capabilityTimeouts = mutableMapOf<String, Runnable>()
|
|
4597
4602
|
val audio = CraftNativeAudio()
|
|
4598
4603
|
val sounds = CraftNativeSounds(view.context)
|
|
4604
|
+
var motion: CraftNativeMotion? = null
|
|
4605
|
+
var barometer: CraftNativeBarometer? = null
|
|
4606
|
+
var pedometer: CraftNativePedometer? = null
|
|
4599
4607
|
val soundRequestCancellers = mutableMapOf<String, () -> Unit>()
|
|
4600
4608
|
/** Snapshot writes awaiting the writer thread; close rejects them once. */
|
|
4601
4609
|
val pendingSyncResults = mutableSetOf<String>()
|
|
@@ -4617,6 +4625,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4617
4625
|
soundRequestCancellers.values.toList().forEach { it() }
|
|
4618
4626
|
soundRequestCancellers.clear()
|
|
4619
4627
|
sounds.close()
|
|
4628
|
+
motion?.close()
|
|
4629
|
+
barometer?.close()
|
|
4630
|
+
pedometer?.close()
|
|
4620
4631
|
// Evaluation completion does not mean its outbound messages have
|
|
4621
4632
|
// reached the host. Close only after the ordered channel barrier,
|
|
4622
4633
|
// so onDestroy's final writes run before navigation continues.
|
|
@@ -4688,6 +4699,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4688
4699
|
private var hapticsEnabled = false
|
|
4689
4700
|
private var clipboardEnabled = false
|
|
4690
4701
|
private var shakeEnabled = false
|
|
4702
|
+
private var motionSensorsEnabled = false
|
|
4703
|
+
private var barometerEnabled = false
|
|
4704
|
+
private var pedometerEnabled = false
|
|
4705
|
+
private var motionForeground = false
|
|
4691
4706
|
internal var nativeAppState = "inactive"
|
|
4692
4707
|
private set
|
|
4693
4708
|
private var deliveredInitialDeepLink = false
|
|
@@ -4723,6 +4738,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4723
4738
|
}
|
|
4724
4739
|
|
|
4725
4740
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
4741
|
+
// The activity starts in the launch theme (the splash); its own theme
|
|
4742
|
+
// takes over before the first frame, on the same background colour.
|
|
4743
|
+
setTheme(R.style.Theme_CraftApp)
|
|
4726
4744
|
applyConfiguredAppearance()
|
|
4727
4745
|
super.onCreate(savedInstanceState)
|
|
4728
4746
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
|
@@ -4761,6 +4779,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4761
4779
|
hapticsEnabled = config.optBoolean("enableHaptics", false)
|
|
4762
4780
|
clipboardEnabled = config.optBoolean("enableClipboard", false)
|
|
4763
4781
|
shakeEnabled = config.optBoolean("enableShakeGesture", false)
|
|
4782
|
+
motionSensorsEnabled = config.optBoolean("enableMotionSensors", false)
|
|
4783
|
+
barometerEnabled = config.optBoolean("enableBarometer", false)
|
|
4784
|
+
pedometerEnabled = config.optBoolean("enablePedometer", false)
|
|
4764
4785
|
// Normally started already by its content provider; this covers a
|
|
4765
4786
|
// process that skipped providers (instrumentation).
|
|
4766
4787
|
CraftCrashReporter.start(this)
|
|
@@ -5153,6 +5174,30 @@ class MainActivity : AppCompatActivity() {
|
|
|
5153
5174
|
activeScreen.view.onLayoutChanged = {
|
|
5154
5175
|
if (activeScreen.active) emitLayoutEvents(activeScreen)
|
|
5155
5176
|
}
|
|
5177
|
+
if (motionSensorsEnabled && !headless) {
|
|
5178
|
+
activeScreen.motion = CraftNativeMotion(CraftAndroidMotionSource(this)).apply {
|
|
5179
|
+
emit = { detail ->
|
|
5180
|
+
if (activeScreen.active && motionForeground) send(activeScreen, "CRAFT_EVENT",
|
|
5181
|
+
JSONObject().put("event", "craftRawMotionUpdate").put("detail", detail))
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
if (barometerEnabled && !headless) {
|
|
5186
|
+
activeScreen.barometer = CraftNativeBarometer(CraftAndroidMotionSource(this)).apply {
|
|
5187
|
+
emit = { detail ->
|
|
5188
|
+
if (activeScreen.active) send(activeScreen, "CRAFT_EVENT",
|
|
5189
|
+
JSONObject().put("event", "craftBarometerUpdate").put("detail", detail))
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
if (pedometerEnabled && !headless) {
|
|
5194
|
+
activeScreen.pedometer = CraftNativePedometer(CraftAndroidMotionSource(this), capabilities::pedometerPermissionGranted).apply {
|
|
5195
|
+
emit = { detail ->
|
|
5196
|
+
if (activeScreen.active) send(activeScreen, "CRAFT_EVENT",
|
|
5197
|
+
JSONObject().put("event", "craftPedometerUpdate").put("detail", detail))
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5156
5201
|
activeScreen.sounds.emit = { payload ->
|
|
5157
5202
|
if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
|
|
5158
5203
|
}
|
|
@@ -5534,6 +5579,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
5534
5579
|
private fun capabilityNames(): JSONArray = capabilities.names().apply {
|
|
5535
5580
|
services.names().forEach { name -> put(name) }
|
|
5536
5581
|
put("audio")
|
|
5582
|
+
if (motionSensorsEnabled) put("rawMotionSensors")
|
|
5583
|
+
if (barometerEnabled) put("barometer")
|
|
5584
|
+
if (pedometerEnabled) put("pedometer")
|
|
5537
5585
|
put("viewFocus")
|
|
5538
5586
|
put("viewMeasure")
|
|
5539
5587
|
put("viewScroll")
|
|
@@ -6952,6 +7000,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6952
7000
|
CraftNativePointers.configure(control, props, style, control is CraftNativeFlexLayout || control is CraftNativeImageBackgroundView ||
|
|
6953
7001
|
control is CraftNativeKeyboardAvoidingView || control is CraftNativeScrollView || control is CraftNativeRefreshableFlatList || control is CraftNativeModalView)
|
|
6954
7002
|
applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
|
|
7003
|
+
applyAccessibilityActions(screen, control, props, events, longPressDisabled)
|
|
6955
7004
|
// One element across an animated change (craft.animation.next).
|
|
6956
7005
|
control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }
|
|
6957
7006
|
val layoutHandler = craftNativeEventName(events, "onLayout")
|
|
@@ -7854,6 +7903,54 @@ class MainActivity : AppCompatActivity() {
|
|
|
7854
7903
|
field.setSelection(minOf(boundedStart, boundedEnd), maxOf(boundedStart, boundedEnd))
|
|
7855
7904
|
}
|
|
7856
7905
|
|
|
7906
|
+
/**
|
|
7907
|
+
* What a hold offers, published where TalkBack can reach it.
|
|
7908
|
+
*
|
|
7909
|
+
* A long click is invisible to a screen reader: nothing announces that it
|
|
7910
|
+
* is there and nothing can perform it, so an action living only behind a
|
|
7911
|
+
* hold does not exist for anyone driving the screen by assistive
|
|
7912
|
+
* technology. A thread that holds a message for Copy and Report is the
|
|
7913
|
+
* case this was written for; without this the honest mitigation is a
|
|
7914
|
+
* permanently visible button under every message.
|
|
7915
|
+
*
|
|
7916
|
+
* Runs after applyAccessibility, which installs its own delegate and
|
|
7917
|
+
* would drop actions added before it.
|
|
7918
|
+
*/
|
|
7919
|
+
private fun applyAccessibilityActions(
|
|
7920
|
+
screen: Screen,
|
|
7921
|
+
control: View,
|
|
7922
|
+
props: JSONObject,
|
|
7923
|
+
events: JSONObject,
|
|
7924
|
+
disabled: Boolean,
|
|
7925
|
+
) {
|
|
7926
|
+
screen.accessibilityActionIds.remove(control)?.forEach { ViewCompat.removeAccessibilityAction(control, it) }
|
|
7927
|
+
val handler = craftNativeEventName(events, "onAccessibilityAction").takeUnless { disabled } ?: ""
|
|
7928
|
+
val authored = props.optJSONArray("accessibilityActions")
|
|
7929
|
+
if (handler.isBlank() || authored == null || authored.length() == 0) {
|
|
7930
|
+
screen.accessibilityActionHandlers.remove(control)
|
|
7931
|
+
return
|
|
7932
|
+
}
|
|
7933
|
+
screen.accessibilityActionHandlers[control] = handler
|
|
7934
|
+
val ids = mutableListOf<Int>()
|
|
7935
|
+
for (index in 0 until authored.length()) {
|
|
7936
|
+
val entry = authored.optJSONObject(index) ?: continue
|
|
7937
|
+
val name = craftNativeOptionalString(entry.opt("name"))?.takeIf { it.isNotBlank() } ?: continue
|
|
7938
|
+
val label = craftNativeOptionalString(entry.opt("label"))?.takeIf { it.isNotBlank() } ?: name
|
|
7939
|
+
ids += ViewCompat.addAccessibilityAction(control, label) { _, _ ->
|
|
7940
|
+
// Read the handler at firing time: a reconcile can rename it
|
|
7941
|
+
// while the action sits in TalkBack's menu.
|
|
7942
|
+
val current = screen.accessibilityActionHandlers[control]
|
|
7943
|
+
if (current.isNullOrBlank()) {
|
|
7944
|
+
false
|
|
7945
|
+
} else {
|
|
7946
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("actionName", name)))
|
|
7947
|
+
true
|
|
7948
|
+
}
|
|
7949
|
+
}
|
|
7950
|
+
}
|
|
7951
|
+
if (ids.isEmpty()) screen.accessibilityActionHandlers.remove(control) else screen.accessibilityActionIds[control] = ids
|
|
7952
|
+
}
|
|
7953
|
+
|
|
7857
7954
|
private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
|
|
7858
7955
|
val testId = craftNativeOptionalString(props.opt("testID"))
|
|
7859
7956
|
view.tag = testId?.takeIf { it.isNotBlank() } ?: fallbackLabel
|
|
@@ -8039,6 +8136,37 @@ class MainActivity : AppCompatActivity() {
|
|
|
8039
8136
|
val method = payload.optString("method")
|
|
8040
8137
|
val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
|
|
8041
8138
|
{ completion -> performView(screen, method, args, completion) }
|
|
8139
|
+
} else if (module == "Pedometer") {
|
|
8140
|
+
{ completion ->
|
|
8141
|
+
val pedometer = screen.pedometer
|
|
8142
|
+
if (!pedometerEnabled) completion(CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Pedometer is disabled"))
|
|
8143
|
+
else if (pedometer == null) completion(CraftNativeCapabilityResult(code = "NEEDS_ACTIVITY", message = "Pedometer needs a visible app"))
|
|
8144
|
+
else if (method in listOf("start", "requestPermission") && !motionForeground) completion(CraftNativeCapabilityResult(code = "APP_INACTIVE", message = "Pedometer needs a foreground app"))
|
|
8145
|
+
else if (method == "requestPermission") capabilities.perform(token, payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION), module, method, args, completion)
|
|
8146
|
+
else completion(pedometer.perform(method, args))
|
|
8147
|
+
}
|
|
8148
|
+
} else if (module == "Barometer") {
|
|
8149
|
+
{ completion ->
|
|
8150
|
+
val barometer = screen.barometer
|
|
8151
|
+
completion(if (!barometerEnabled) {
|
|
8152
|
+
CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Barometer is disabled")
|
|
8153
|
+
} else if (barometer == null) {
|
|
8154
|
+
CraftNativeCapabilityResult(code = "NEEDS_ACTIVITY", message = "Barometer needs a visible app")
|
|
8155
|
+
} else if (method == "start" && !motionForeground) {
|
|
8156
|
+
CraftNativeCapabilityResult(code = "APP_INACTIVE", message = "Barometer needs a foreground app")
|
|
8157
|
+
} else barometer.perform(method, args))
|
|
8158
|
+
}
|
|
8159
|
+
} else if (module == "MotionSensors") {
|
|
8160
|
+
{ completion ->
|
|
8161
|
+
val motion = screen.motion
|
|
8162
|
+
completion(if (!motionSensorsEnabled) {
|
|
8163
|
+
CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Motion sensors are disabled")
|
|
8164
|
+
} else if (motion == null) {
|
|
8165
|
+
CraftNativeCapabilityResult(code = "NEEDS_ACTIVITY", message = "Motion sensors need a visible app")
|
|
8166
|
+
} else if (method == "start" && !motionForeground) {
|
|
8167
|
+
CraftNativeCapabilityResult(code = "APP_INACTIVE", message = "Motion sensors need a foreground app")
|
|
8168
|
+
} else motion.perform(method, args))
|
|
8169
|
+
}
|
|
8042
8170
|
} else if (module == "Audio") {
|
|
8043
8171
|
{ completion ->
|
|
8044
8172
|
val id = args.optString(0)
|
|
@@ -8290,6 +8418,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
8290
8418
|
}
|
|
8291
8419
|
|
|
8292
8420
|
override fun onPause() {
|
|
8421
|
+
motionForeground = false
|
|
8422
|
+
allScreens().forEach { it.motion?.stopAll(); it.barometer?.stop("background"); it.pedometer?.stop("background") }
|
|
8293
8423
|
disarmShakeGesture()
|
|
8294
8424
|
if (::services.isInitialized) services.pauseVoice()
|
|
8295
8425
|
nativeAppState = "inactive"
|
|
@@ -8350,13 +8480,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
8350
8480
|
|
|
8351
8481
|
override fun onResume() {
|
|
8352
8482
|
super.onResume()
|
|
8483
|
+
motionForeground = true
|
|
8353
8484
|
armShakeGesture()
|
|
8354
8485
|
if (::services.isInitialized) services.resumed()
|
|
8355
8486
|
}
|
|
8356
8487
|
|
|
8357
8488
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
|
8358
8489
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
8359
|
-
if (::capabilities.isInitialized) { capabilities.onBluetoothPermissions(requestCode); capabilities.onNotificationPermissions(requestCode) }
|
|
8490
|
+
if (::capabilities.isInitialized) { capabilities.onBluetoothPermissions(requestCode); capabilities.onNotificationPermissions(requestCode); capabilities.onPedometerPermissions(requestCode) }
|
|
8360
8491
|
if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
|
|
8361
8492
|
}
|
|
8362
8493
|
|