@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.
@@ -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
- start(context, id, System.currentTimeMillis(), requestedPowerMode(options, "default"))
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" -> state(context, options = options)
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
- if (includeLocations) put("locations", locations)
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
- callback = null
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