craft-native 0.0.237 → 0.0.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +71 -0
  2. package/dist/android/src/emulator.d.ts +23 -0
  3. package/dist/android/src/index.d.ts +7 -1
  4. package/dist/android/src/index.js +280 -98
  5. package/dist/android/templates/CraftBridge.kt.template +65 -54
  6. package/dist/android/templates/CraftNative.kt.template +27 -0
  7. package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
  8. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  9. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  10. package/dist/android/templates/LocationRecordingService.kt.template +252 -46
  11. package/dist/android-adb.d.ts +2 -0
  12. package/dist/api/index.d.ts +1 -1
  13. package/dist/api/mobile.d.ts +49 -14
  14. package/dist/browser/storage.d.ts +3 -0
  15. package/dist/browser.js +163 -51
  16. package/dist/cli.js +1 -1
  17. package/dist/index.cjs +126 -12
  18. package/dist/index.js +126 -12
  19. package/dist/ios/src/index.d.ts +5 -0
  20. package/dist/ios/src/index.js +9 -1
  21. package/dist/ios/src/testing.d.ts +2 -0
  22. package/dist/ios/templates/CraftApp.swift +376 -88
  23. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  24. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  25. package/dist/ios/templates/CraftNativeScreen.swift +11 -4
  26. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  27. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  28. package/dist/ios/templates/testing/DriverTests.swift.template +8 -1
  29. package/dist/mobile-testing.js +30 -4
  30. package/dist/mobile.js +169 -17
  31. package/package.json +1 -1
@@ -78,7 +78,12 @@ internal object CraftNativeHub {
78
78
  /** A screen of the app found for a path. */
79
79
  internal data class CraftNativeRouteMatch(val screen: String, val params: JSONObject, val path: String)
80
80
 
81
- internal data class CraftNativeTab(val id: String, val title: String, val symbol: String)
81
+ internal data class CraftNativeTab(
82
+ val id: String, val title: String, val symbol: String,
83
+ val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
84
+ )
85
+
86
+ internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
82
87
 
83
88
  /** The app's paths (`nativeScreens`), its tabs and its site. */
84
89
  internal class CraftNativeRoutes(config: JSONObject) {
@@ -107,7 +112,10 @@ internal class CraftNativeRoutes(config: JSONObject) {
107
112
  for (index in 0 until list.length()) {
108
113
  val tab = list.optJSONObject(index) ?: continue
109
114
  val id = tab.optString("id").takeIf { it.isNotBlank() } ?: continue
110
- add(CraftNativeTab(normalize(id), tab.optString("title", id), tab.optString("symbol")))
115
+ add(CraftNativeTab(normalize(id), tab.optString("title", id), tab.optString("symbol"),
116
+ tab.optString("selectedSymbol").trim().takeUnless { tab.isNull("selectedSymbol") || it.isBlank() },
117
+ tab.optString("selectedIconTint").takeUnless { tab.isNull("selectedIconTint") || it.isBlank() },
118
+ if (tab.opt("selectedIconPulse") is Boolean) tab.optBoolean("selectedIconPulse") else null))
111
119
  }
112
120
  }
113
121
 
@@ -226,6 +234,39 @@ internal class CraftNativeRoutes(config: JSONObject) {
226
234
  *
227
235
  * Writes happen on one background thread, in order.
228
236
  */
237
+ /** One process-wide boundary for committed storage reads and mutations. */
238
+ internal object CraftNativeStorage {
239
+ val lock = Any()
240
+ fun jsonEqual(left: Any?, right: Any?): Boolean {
241
+ if (left == null || left === JSONObject.NULL) return right == null || right === JSONObject.NULL
242
+ if (left is Boolean || right is Boolean) return left is Boolean && right is Boolean && left == right
243
+ if (left is Number && right is Number) return java.math.BigDecimal(left.toString()).compareTo(java.math.BigDecimal(right.toString())) == 0
244
+ if (left is JSONObject && right is JSONObject) return left.length() == right.length() && left.keys().asSequence().all { right.has(it) && jsonEqual(left.get(it), right.get(it)) }
245
+ if (left is JSONArray && right is JSONArray) return left.length() == right.length() && (0 until left.length()).all { jsonEqual(left.get(it), right.get(it)) }
246
+ return left is String && right is String && left == right
247
+ }
248
+ fun commit(storage: SharedPreferences, update: (android.content.SharedPreferences.Editor) -> Unit): Boolean {
249
+ val previous = storage.all
250
+ val editor = storage.edit()
251
+ update(editor)
252
+ if (editor.commit()) return true
253
+ // A failed commit has already changed SharedPreferences' memory map.
254
+ // Restore it before a later draft read can mistake it for a checkpoint.
255
+ val rollback = storage.edit().clear()
256
+ previous.forEach { (key, value) -> when (value) {
257
+ is String -> rollback.putString(key, value)
258
+ is Boolean -> rollback.putBoolean(key, value)
259
+ is Long -> rollback.putLong(key, value)
260
+ is Int -> rollback.putInt(key, value)
261
+ is Float -> rollback.putFloat(key, value)
262
+ else -> error("Invalid stored value")
263
+ } }
264
+ rollback.commit()
265
+ return false
266
+ }
267
+
268
+ }
269
+
229
270
  internal class CraftNativeStores private constructor(context: Context, private val secureEnabled: Boolean) {
230
271
  private val appContext = context.applicationContext
231
272
  private val storage: SharedPreferences = appContext.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
@@ -281,7 +322,7 @@ internal class CraftNativeStores private constructor(context: Context, private v
281
322
  out.append('}')
282
323
  }
283
324
  out.append('{')
284
- section("storage", storage.all.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }.toMap())
325
+ section("storage", synchronized(CraftNativeStorage.lock) { storage.all.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }.toMap() })
285
326
  out.append(',')
286
327
  section("secure", runCatching { secure?.all?.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }?.toMap() }.getOrNull() ?: emptyMap())
287
328
  out.append(',')
@@ -297,8 +338,14 @@ internal class CraftNativeStores private constructor(context: Context, private v
297
338
 
298
339
  /** `craft.storage.setSync`: null removes the key. Tells every isolate. */
299
340
  fun setStorage(key: String, json: String?, from: String?) {
300
- if (json == null) storage.edit().remove(key).apply() else storage.edit().putString(key, json).apply()
301
- changed("storage", key, json, from)
341
+ synchronized(CraftNativeStorage.lock) {
342
+ val previous = storage.getString(key, null)
343
+ val kept = CraftNativeStorage.commit(storage) {
344
+ if (json == null) it.remove(key) else it.putString(key, json)
345
+ }
346
+ if (kept) changed("storage", key, json, from)
347
+ else changed("storage", key, previous, null)
348
+ }
302
349
  }
303
350
 
304
351
  /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */
@@ -647,9 +694,13 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
647
694
  private val tabAccessibilityLabels = mutableMapOf<String, String>()
648
695
  private val tabIconTints = mutableMapOf<String, Int>()
649
696
  private val tabIconPulses = mutableSetOf<String>()
697
+ private val selectedSymbols = mutableMapOf<String, String>()
698
+ private val selectedIconTints = mutableMapOf<String, Int>()
699
+ private val selectedIconPulses = mutableMapOf<String, Boolean>()
650
700
  private val tabImageURLs = mutableMapOf<String, String>()
651
701
  private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
652
702
  private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
703
+ private var renderedButtonSpecs: Pair<String, String>? = null
653
704
  private val density = resources.displayMetrics.density
654
705
  private var screenBackgroundColor: Int? = null
655
706
  private var selectedTab = 0
@@ -705,6 +756,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
705
756
  tabBar.orientation = HORIZONTAL
706
757
  tabBar.gravity = Gravity.CENTER
707
758
  tabs.forEachIndexed { index, tab ->
759
+ tab.selectedSymbol?.trim()?.takeIf { it.isNotEmpty() }?.let { selectedSymbols[tab.id] = it }
760
+ tab.selectedIconTint?.let { runCatching { Color.parseColor(it) }.getOrNull() }?.let { selectedIconTints[tab.id] = it }
761
+ tab.selectedIconPulse?.let { selectedIconPulses[tab.id] = it }
708
762
  val cell = LinearLayout(context)
709
763
  cell.orientation = VERTICAL
710
764
  cell.gravity = Gravity.CENTER
@@ -812,18 +866,24 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
812
866
  titleView.text = if (large) "" else title
813
867
  largeTitleView.text = title
814
868
  largeTitleView.visibility = if (!hidden && large && title.isNotBlank()) View.VISIBLE else View.GONE
815
- buttons.removeAllViews()
816
- leadingButtons.removeAllViews()
817
- navButtonSpecs.clear()
818
869
  val leading = options.optJSONArray("leftButtons") ?: JSONArray()
819
- for (index in 0 until leading.length()) {
820
- val button = leading.optJSONObject(index) ?: continue
821
- leadingButtons.addView(navButton(button))
822
- }
823
870
  val list = options.optJSONArray("rightButtons") ?: JSONArray()
824
- for (index in 0 until list.length()) {
825
- val button = list.optJSONObject(index) ?: continue
826
- buttons.addView(navButton(button))
871
+ // The activity passes cumulative screen options, even for a live BPM
872
+ // patch. Keep unchanged toolbar views, images and touch targets alive.
873
+ val buttonSpecs = leading.toString() to list.toString()
874
+ if (buttonSpecs != renderedButtonSpecs) {
875
+ buttons.removeAllViews()
876
+ leadingButtons.removeAllViews()
877
+ navButtonSpecs.clear()
878
+ for (index in 0 until leading.length()) {
879
+ val button = leading.optJSONObject(index) ?: continue
880
+ leadingButtons.addView(navButton(button))
881
+ }
882
+ for (index in 0 until list.length()) {
883
+ val button = list.optJSONObject(index) ?: continue
884
+ buttons.addView(navButton(button))
885
+ }
886
+ renderedButtonSpecs = buttonSpecs
827
887
  }
828
888
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
829
889
  tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
@@ -869,6 +929,18 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
869
929
  if (item.has("iconPulse")) {
870
930
  if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
871
931
  }
932
+ if (item.has("selectedSymbol")) {
933
+ selectedSymbols.remove(id)
934
+ item.optString("selectedSymbol").trim().takeUnless { item.isNull("selectedSymbol") || it.isBlank() }?.let { selectedSymbols[id] = it }
935
+ }
936
+ if (item.has("selectedIconTint")) {
937
+ selectedIconTints.remove(id)
938
+ if (!item.isNull("selectedIconTint")) runCatching { Color.parseColor(item.optString("selectedIconTint")) }.getOrNull()?.let { selectedIconTints[id] = it }
939
+ }
940
+ if (item.has("selectedIconPulse")) {
941
+ selectedIconPulses.remove(id)
942
+ if (item.opt("selectedIconPulse") is Boolean) selectedIconPulses[id] = item.optBoolean("selectedIconPulse")
943
+ }
872
944
  if (item.has("image")) {
873
945
  val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
874
946
  if (address != tabImageURLs[id]) {
@@ -892,7 +964,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
892
964
  }
893
965
  }
894
966
  }
895
- refreshColors()
967
+ }
968
+
969
+ /** Selection belongs to the native tab stack, including retained pushed screens. */
970
+ internal fun tabIconAppearance(index: Int, motionAllowed: Boolean): CraftTabIconAppearance {
971
+ val tab = tabs[index]
972
+ val selected = index == selectedTab
973
+ return CraftTabIconAppearance(
974
+ if (selected) selectedSymbols[tab.id] ?: tab.symbol else tab.symbol,
975
+ if (selected) selectedIconTints[tab.id] ?: tabIconTints[tab.id] else tabIconTints[tab.id],
976
+ (if (selected) selectedIconPulses[tab.id] ?: tabIconPulses.contains(tab.id) else tabIconPulses.contains(tab.id)) && motionAllowed,
977
+ )
896
978
  }
897
979
 
898
980
  /** Light or dark changed: the bars follow the screen's background. */
@@ -917,13 +999,14 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
917
999
  val color = if (index == selectedTab) accent else muted
918
1000
  val id = tabs[index].id
919
1001
  val number = tabIconText[id]
920
- val iconColor = tabIconTints[id] ?: if (number != null) Color.parseColor("#FF2D55") else color
1002
+ val appearance = tabIconAppearance(index, hasWindowFocus() && tabRow.isShown && android.animation.ValueAnimator.areAnimatorsEnabled())
1003
+ val iconColor = appearance.tint ?: if (number != null) Color.parseColor("#FF2D55") else color
921
1004
  icon.setTextColor(iconColor)
922
1005
  label.text = tabTitles[id] ?: tabs[index].title
923
1006
  tabBar.getChildAt(index).contentDescription = tabAccessibilityLabels[id] ?: label.text
924
1007
  label.setTextColor(color)
925
- val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, iconColor) else null
926
- val pulse = tabIconPulses.contains(id) && number == null && tabImages[id] == null && hasWindowFocus() && android.animation.ValueAnimator.areAnimatorsEnabled()
1008
+ val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, appearance.symbol, iconColor) else null
1009
+ val pulse = appearance.pulse && number == null && tabImages[id] == null
927
1010
  if (pulse && icon.animation == null) {
928
1011
  icon.startAnimation(android.view.animation.AlphaAnimation(1f, 0.55f).apply {
929
1012
  duration = 700
@@ -935,7 +1018,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
935
1018
  icon.alpha = 1f
936
1019
  }
937
1020
  icon.foreground = vector
938
- icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
1021
+ icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(appearance.symbol)
939
1022
  }
940
1023
  navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
941
1024
  }
@@ -21,71 +21,246 @@ import org.json.JSONArray
21
21
  import org.json.JSONObject
22
22
  import java.io.File
23
23
 
24
+ class CraftRecordingException(val code: String, message: String) : IllegalStateException(message)
25
+
24
26
  object CraftLocationRecordingStore {
25
27
  private const val PREFS = "craft_location_recording"
26
28
  private const val FILE = "craft-location-recording.jsonl"
27
-
28
29
  private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
29
30
  private fun file(context: Context) = File(context.filesDir, FILE)
31
+ private fun archive(context: Context, id: String): File {
32
+ val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8))
33
+ return File(context.filesDir, "craft-location-completed-${digest.joinToString("") { "%02x".format(it) }}.json")
34
+ }
35
+ private fun acknowledged(context: Context, id: String): Boolean {
36
+ val marker = File(archive(context, id).path.replace("-completed-", "-ack-"))
37
+ if (!marker.exists()) return false
38
+ if (marker.readText() != id) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid recording acknowledgement")
39
+ return true
40
+ }
41
+ private fun writeAtomic(destination: File, value: String) {
42
+ val temporary = File.createTempFile(".craft-recording-", ".tmp", destination.parentFile)
43
+ try {
44
+ java.io.FileOutputStream(temporary).use { stream -> stream.write(value.toByteArray(Charsets.UTF_8)); stream.fd.sync() }
45
+ java.nio.file.Files.move(temporary.toPath(), destination.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
46
+ } finally { temporary.delete() }
47
+ }
48
+ private fun expected(options: JSONObject?): String? {
49
+ if (options == null || !options.has("expectedId")) return null
50
+ val id = options.opt("expectedId")
51
+ if (id !is String || id.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "expectedId must be a nonempty recording ID")
52
+ return id
53
+ }
54
+ private fun requireOwner(context: Context, options: JSONObject?) {
55
+ val id = expected(options) ?: return
56
+ if (id != prefs(context).getString("id", null)) throw CraftRecordingException("RECORDING_CONFLICT", "The active location recording has changed")
57
+ }
58
+ private fun completed(context: Context, id: String?): JSONObject? {
59
+ if (id == null) return null
60
+ val source = archive(context, id)
61
+ if (acknowledged(context, id)) {
62
+ if (source.exists() && !source.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged recording")
63
+ throw CraftRecordingException("RECORDING_NOT_FOUND", "This recording has been acknowledged")
64
+ }
65
+ if (!source.exists()) return null
66
+ val saved = JSONObject(source.readText())
67
+ if (saved.optString("id") != id || saved.optBoolean("active") || saved.optJSONArray("locations") == null)
68
+ throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid completed recording archive")
69
+ return saved
70
+ }
71
+ private fun archiveCompleted(context: Context) {
72
+ val id = prefs(context).getString("id", null) ?: return
73
+ if (acknowledged(context, id) || completed(context, id) != null) return
74
+ val saved = state(context, includeLocations = true)
75
+ val destination = archive(context, id)
76
+ writeAtomic(destination, saved.toString())
77
+ }
78
+ private fun edit(context: Context, update: (android.content.SharedPreferences.Editor) -> Unit) {
79
+ val values = prefs(context)
80
+ val previous = values.all.toMap()
81
+ val editor = values.edit()
82
+ update(editor)
83
+ if (editor.commit()) return
84
+ // SharedPreferences publishes its memory map before reporting disk
85
+ // failure. Restore it too, so a rejected operation cannot change owner.
86
+ val rollback = values.edit().clear()
87
+ previous.forEach { (key, value) -> when (value) {
88
+ is String -> rollback.putString(key, value)
89
+ is Boolean -> rollback.putBoolean(key, value)
90
+ is Long -> rollback.putLong(key, value)
91
+ is Int -> rollback.putInt(key, value)
92
+ is Float -> rollback.putFloat(key, value)
93
+ else -> throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid recording metadata")
94
+ } }
95
+ rollback.commit()
96
+ throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to persist recording state")
97
+ }
98
+ private fun commit(context: Context, active: Boolean, paused: Boolean) {
99
+ edit(context) { it.putBoolean("active", active).putBoolean("paused", paused) }
100
+ }
101
+
102
+ @Synchronized
103
+ fun recover(context: Context) {
104
+ val id = recordingId(context) ?: return
105
+ if (!acknowledged(context, id)) return
106
+ val saved = archive(context, id)
107
+ if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged recording")
108
+ if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged samples")
109
+ edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
110
+ listener = null
111
+ }
30
112
 
31
113
  @Synchronized
32
114
  fun start(context: Context, id: String, startedAt: Long) {
115
+ recover(context)
116
+ if (isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "A location recording is already active")
117
+ if (id == recordingId(context) || archive(context, id).exists() || acknowledged(context, id))
118
+ throw CraftRecordingException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
119
+ archiveCompleted(context)
33
120
  file(context).writeText("")
34
- prefs(context).edit()
35
- .putString("id", id)
36
- .putBoolean("active", true)
37
- .putBoolean("paused", false)
38
- .putLong("startedAt", startedAt)
39
- .apply()
121
+ edit(context) { it.putString("id", id).putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt) }
40
122
  }
41
123
 
42
124
  @Synchronized
43
- fun setPaused(context: Context, paused: Boolean) {
44
- prefs(context).edit().putBoolean("paused", paused).apply()
125
+ fun updatePause(context: Context, paused: Boolean, options: JSONObject?): JSONObject {
126
+ requireOwner(context, options)
127
+ if (!isActive(context)) throw CraftRecordingException("NO_ACTIVE_RECORDING", "No active location recording")
128
+ commit(context, true, paused)
129
+ return state(context)
45
130
  }
131
+ @Synchronized fun setPaused(context: Context, paused: Boolean) { updatePause(context, paused, null) }
46
132
 
47
133
  @Synchronized
48
- fun stop(context: Context) {
49
- prefs(context).edit().putBoolean("active", false).putBoolean("paused", false).apply()
134
+ fun finish(context: Context, options: JSONObject?): JSONObject {
135
+ completed(context, expected(options))?.let { return it }
136
+ requireOwner(context, options)
137
+ val stopped = state(context, includeLocations = true)
138
+ commit(context, false, false)
139
+ listener = null
140
+ return stopped.put("active", false).put("paused", false)
141
+ }
142
+ @Synchronized fun stop(context: Context) { finish(context, null) }
143
+ @Synchronized
144
+ fun acknowledge(context: Context, options: JSONObject?): Boolean {
145
+ val id = expected(options) ?: throw CraftRecordingException("INVALID_ARGUMENT", "Acknowledgement needs expectedId")
146
+ val current = recordingId(context) == id
147
+ if (current && isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "The recording is still active")
148
+ if (!current && !acknowledged(context, id) && completed(context, id) == null)
149
+ throw CraftRecordingException("RECORDING_CONFLICT", "Unknown recording ID")
150
+ val saved = archive(context, id)
151
+ val marker = File(saved.path.replace("-completed-", "-ack-"))
152
+ if (!marker.exists()) writeAtomic(marker, id)
153
+ if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove completed recording")
154
+ if (current) {
155
+ if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove recording samples")
156
+ edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
157
+ listener = null
158
+ }
159
+ return true
50
160
  }
51
161
 
52
- fun isActive(context: Context) = prefs(context).getBoolean("active", false)
53
- fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
162
+ /** One native backend used by the STX host and the page's JNI entry point. */
163
+ @JvmStatic
164
+ fun perform(context: Context, method: String, optionsJson: String): String = synchronized(this) {
165
+ return try {
166
+ recover(context)
167
+ val options = JSONObject(optionsJson)
168
+ val result: Any = when (method) {
169
+ "startRecording" -> {
170
+ val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
171
+ if (!permitted) throw CraftRecordingException("PERMISSION_DENIED", "Location is not allowed")
172
+ val id = if (options.has("recordingId")) {
173
+ val requested = options.opt("recordingId")
174
+ if (requested !is String || requested.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "recordingId must be a nonempty recording ID")
175
+ requested
176
+ } else java.util.UUID.randomUUID().toString()
177
+ start(context, id, System.currentTimeMillis())
178
+ try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", id)) }
179
+ catch (error: Exception) { finish(context, JSONObject().put("expectedId", id)); throw error }
180
+ state(context)
181
+ }
182
+ "pauseRecording" -> updatePause(context, true, options)
183
+ "resumeRecording" -> {
184
+ val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
185
+ if (!permitted) throw CraftRecordingException("PERMISSION_DENIED", "Location is not allowed")
186
+ val wasPaused = isPaused(context)
187
+ val resumed = updatePause(context, false, options)
188
+ try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", recordingId(context))) }
189
+ catch (error: Exception) { updatePause(context, wasPaused, options); throw error }
190
+ resumed
191
+ }
192
+ "stopRecording" -> {
193
+ val stopped = finish(context, options)
194
+ if (stopped.optString("id") == recordingId(context) && !isActive(context))
195
+ context.startService(Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP).putExtra("recordingId", stopped.optString("id")))
196
+ stopped
197
+ }
198
+ "getRecordingState" -> state(context, options = options)
199
+ "readRecording" -> read(context, options)
200
+ "acknowledgeRecording" -> acknowledge(context, options)
201
+ else -> throw CraftRecordingException("UNKNOWN_ACTION", "Unsupported Location method")
202
+ }
203
+ JSONObject().put("ok", true).put("data", result).toString()
204
+ } catch (error: Exception) {
205
+ JSONObject().put("ok", false).put("code", (error as? CraftRecordingException)?.code ?: "RECORDING_STORAGE_ERROR").put("message", error.message ?: "Recording failed").toString()
206
+ }
207
+ }
54
208
 
55
- /**
56
- * Hears each fix the recording keeps, while the app is running: a native
57
- * screen shows the route growing (`craftLocationUpdate`).
58
- */
59
- @Volatile var listener: ((android.location.Location) -> Unit)? = null
209
+ @Synchronized fun isActive(context: Context) = prefs(context).getBoolean("active", false)
210
+ @Synchronized fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
211
+ @Synchronized fun recordingId(context: Context): String? = prefs(context).getString("id", null)
212
+ @Volatile var listener: ((android.location.Location, String) -> Unit)? = null
213
+ @Volatile var nativeListener: ((android.location.Location, String) -> Unit)? = null
60
214
 
61
215
  @Synchronized
62
- fun append(context: Context, location: android.location.Location) {
63
- if (!isActive(context) || isPaused(context)) return
64
- listener?.invoke(location)
65
- val json = JSONObject().apply {
66
- put("latitude", location.latitude)
67
- put("longitude", location.longitude)
68
- put("accuracy", location.accuracy)
69
- put("altitude", location.altitude)
70
- put("speed", location.speed)
71
- put("heading", location.bearing)
72
- put("timestamp", location.time)
216
+ fun listen(context: Context, id: String, callback: (android.location.Location, String) -> Unit) {
217
+ if (recordingId(context) == id && isActive(context)) listener = callback
218
+ }
219
+
220
+ @JvmOverloads
221
+ fun append(context: Context, location: android.location.Location, expectedId: String? = null) {
222
+ val notify = synchronized(this) {
223
+ val id = recordingId(context) ?: return
224
+ if (!isActive(context) || isPaused(context) || (expectedId != null && expectedId != id)) return
225
+ val json = JSONObject().apply {
226
+ put("latitude", location.latitude); put("longitude", location.longitude)
227
+ put("accuracy", location.accuracy); put("altitude", location.altitude)
228
+ put("speed", location.speed); put("heading", location.bearing)
229
+ put("timestamp", location.time); put("recordingId", recordingId(context))
230
+ }
231
+ file(context).appendText("$json\n")
232
+ Triple(listener, nativeListener, id)
73
233
  }
74
- file(context).appendText("$json\n")
234
+ // The event keeps the owner captured before releasing the store lock.
235
+ notify.first?.invoke(location, notify.third)
236
+ notify.second?.invoke(location, notify.third)
75
237
  }
76
238
 
77
239
  @Synchronized
78
240
  fun locations(context: Context): JSONArray {
79
241
  val result = JSONArray()
80
242
  val source = file(context)
81
- if (!source.exists()) return result
82
- source.forEachLine { line ->
83
- if (line.isNotBlank()) runCatching { result.put(JSONObject(line)) }
243
+ if (!source.exists()) {
244
+ val id = recordingId(context)
245
+ if (id != null && !acknowledged(context, id)) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Recording samples are missing")
246
+ return result
84
247
  }
248
+ source.forEachLine { line -> if (line.isNotBlank()) runCatching { result.put(JSONObject(line)) } }
85
249
  return result
86
250
  }
87
-
88
- fun state(context: Context, includeLocations: Boolean = false): JSONObject {
251
+ @Synchronized
252
+ fun read(context: Context, options: JSONObject?): JSONArray {
253
+ completed(context, expected(options))?.let { return it.getJSONArray("locations") }
254
+ requireOwner(context, options)
255
+ return locations(context)
256
+ }
257
+ @Synchronized
258
+ fun state(context: Context, includeLocations: Boolean = false, options: JSONObject? = null): JSONObject {
259
+ completed(context, expected(options))?.let {
260
+ if (!includeLocations) it.remove("locations")
261
+ return it
262
+ }
263
+ requireOwner(context, options)
89
264
  val values = prefs(context)
90
265
  val locations = locations(context)
91
266
  return JSONObject().apply {
@@ -102,6 +277,8 @@ object CraftLocationRecordingStore {
102
277
  class LocationRecordingService : Service() {
103
278
  private val client by lazy { LocationServices.getFusedLocationProviderClient(this) }
104
279
  private var callback: LocationCallback? = null
280
+ private var callbackRecordingId: String? = null
281
+ private var serviceStartId = 0
105
282
 
106
283
  override fun onCreate() {
107
284
  super.onCreate()
@@ -120,28 +297,52 @@ class LocationRecordingService : Service() {
120
297
  }
121
298
 
122
299
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
300
+ return synchronized(CraftLocationRecordingStore) {
301
+ try { CraftLocationRecordingStore.recover(this) }
302
+ catch (error: Exception) {
303
+ android.util.Log.e("CraftLocation", "Unable to restore recording", error)
304
+ stopUpdates()
305
+ stopSelfResult(startId)
306
+ return START_NOT_STICKY
307
+ }
308
+ val expected = intent?.getStringExtra("recordingId")
309
+ val current = CraftLocationRecordingStore.recordingId(this)
310
+ if (expected != null && expected != current) {
311
+ // ACK can clear A before its queued service intent arrives. Only
312
+ // tear down an unowned service; a newer B must keep its callback.
313
+ if (current == null && !CraftLocationRecordingStore.isActive(this)) {
314
+ stopUpdates()
315
+ stopSelfResult(startId)
316
+ return START_NOT_STICKY
317
+ }
318
+ return START_STICKY
319
+ }
320
+ serviceStartId = startId
123
321
  when (intent?.action) {
124
322
  ACTION_STOP -> {
125
323
  stopUpdates()
126
- stopSelf()
324
+ stopSelfResult(startId)
127
325
  return START_NOT_STICKY
128
326
  }
129
327
  else -> if (!startUpdates()) {
130
- stopSelf()
328
+ stopSelfResult(startId)
131
329
  return START_NOT_STICKY
132
330
  }
133
331
  }
134
- return START_STICKY
332
+ START_STICKY
333
+ }
135
334
  }
136
335
 
137
336
  private fun startUpdates(): Boolean {
138
- if (callback != null) return true
337
+ val id = CraftLocationRecordingStore.recordingId(this) ?: return false
338
+ if (callback != null && callbackRecordingId == id) return true
339
+ stopUpdates()
139
340
  if (!CraftLocationRecordingStore.isActive(this)) return false
140
341
  val hasForegroundLocation =
141
342
  ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
142
343
  ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
143
344
  if (!hasForegroundLocation) {
144
- CraftLocationRecordingStore.stop(this)
345
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
145
346
  return false
146
347
  }
147
348
  val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000)
@@ -150,23 +351,27 @@ class LocationRecordingService : Service() {
150
351
  .build()
151
352
  val nextCallback = object : LocationCallback() {
152
353
  override fun onLocationResult(result: LocationResult) {
153
- for (location in result.locations) CraftLocationRecordingStore.append(this@LocationRecordingService, location)
354
+ for (location in result.locations) CraftLocationRecordingStore.append(this@LocationRecordingService, location, id)
154
355
  }
155
356
  }
156
357
  callback = nextCallback
358
+ callbackRecordingId = id
157
359
  return try {
158
360
  client.requestLocationUpdates(request, nextCallback, Looper.getMainLooper())
159
361
  .addOnFailureListener {
160
- if (callback === nextCallback) {
161
- CraftLocationRecordingStore.stop(this)
162
- stopUpdates()
163
- stopSelf()
362
+ synchronized(CraftLocationRecordingStore) {
363
+ if (callback === nextCallback && CraftLocationRecordingStore.recordingId(this) == id) {
364
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
365
+ .onFailure { android.util.Log.e("CraftLocation", "Unable to persist failed recording", it) }
366
+ stopUpdates()
367
+ stopSelfResult(serviceStartId)
368
+ }
164
369
  }
165
370
  }
166
371
  true
167
372
  } catch (error: Exception) {
168
373
  callback = null
169
- CraftLocationRecordingStore.stop(this)
374
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
170
375
  false
171
376
  }
172
377
  }
@@ -174,6 +379,7 @@ class LocationRecordingService : Service() {
174
379
  private fun stopUpdates() {
175
380
  callback?.let { client.removeLocationUpdates(it) }
176
381
  callback = null
382
+ callbackRecordingId = null
177
383
  }
178
384
 
179
385
  override fun onDestroy() {
@@ -0,0 +1,2 @@
1
+ /** Restart adbd once and require a rooted, explicitly selected disposable emulator. */
2
+ export declare function requireAndroidEmulatorRoot(adb: string, serial: string, timeoutMs?: number): Promise<void>;
@@ -17,7 +17,7 @@ export type { HttpClientOptions, RequestOptions, HttpResponse, WebSocketOptions
17
17
  export { crypto, uuid, randomString, hmac, timingSafeEqual, hashPassword, verifyPassword } from './crypto.js';
18
18
  export { env, getPlatform, isDesktop, isMobile, isCraft, getSystemInfo, exec, spawn, ChildProcess, cwd, homeDir, tempDir, exit, argv, open } from './process.js';
19
19
  export type { Platform, SystemInfo, ExecOptions, ExecResult, SpawnOptions } from './process.js';
20
- export { device, haptics, permissions, camera, biometrics, secureStorage, location, share, lifecycle, notifications, notifications as notification } from './mobile.js';
20
+ export { device, haptics, permissions, camera, biometrics, secureStorage, storage, location, share, lifecycle, notifications, notifications as notification } from './mobile.js';
21
21
  export type { DeviceInfo, DeviceCapabilities, HapticStyle, HapticNotificationType, PermissionType, PermissionStatus, CameraOptions as MobileCameraOptions, PhotoResult, BiometricType, Location, LocationOptions, ShareOptions, AppState, NotificationOptions as MobileNotificationOptions } from './mobile.js';
22
22
  export { carplay, appClips, liveActivities, sharePlay, storeKit, appIntents, tipKit, focusFilters } from './ios-advanced.js';
23
23
  export type { CarPlayTemplateType, CarPlayListItem, CarPlayGridItem, CarPlayTemplate, AppClipInvocation, LiveActivityContentState, LiveActivityAttributes, LiveActivityConfig, LiveActivityHandle, SharePlaySessionState, SharePlayParticipant, SharePlayActivity, ProductType, Product, Transaction, IntentParameterType, IntentParameter, AppIntent, TipDisplayFrequency, Tip, FocusStatus, FocusFilter } from './ios-advanced.js';