craft-native 0.0.237 → 0.0.241

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 (40) 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 +24 -2
  4. package/dist/android/src/index.js +362 -103
  5. package/dist/android/src/native-map.d.ts +7 -0
  6. package/dist/android/templates/CraftBridge.kt.template +67 -55
  7. package/dist/android/templates/CraftNative.kt.template +27 -0
  8. package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  11. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  12. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  13. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  14. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  15. package/dist/android/templates/LocationRecordingService.kt.template +255 -47
  16. package/dist/android/templates/MainActivityNative.kt.template +7 -428
  17. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  18. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  19. package/dist/android-adb.d.ts +2 -0
  20. package/dist/api/index.d.ts +1 -1
  21. package/dist/api/mobile.d.ts +49 -14
  22. package/dist/browser/storage.d.ts +3 -0
  23. package/dist/browser.js +163 -51
  24. package/dist/cli.js +1 -1
  25. package/dist/index.cjs +126 -12
  26. package/dist/index.js +126 -12
  27. package/dist/ios/src/index.d.ts +5 -0
  28. package/dist/ios/src/index.js +17 -1
  29. package/dist/ios/src/testing.d.ts +16 -2
  30. package/dist/ios/templates/CraftApp.swift +418 -93
  31. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  32. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  33. package/dist/ios/templates/CraftNativeScreen.swift +82 -17
  34. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  35. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  36. package/dist/ios/templates/testing/DriverTests.swift.template +45 -1
  37. package/dist/macos/templates/native_layout.zig +41 -5
  38. package/dist/mobile-testing.js +30 -4
  39. package/dist/mobile.js +169 -17
  40. 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
  }
@@ -12,6 +12,7 @@ import android.os.IBinder
12
12
  import android.os.Looper
13
13
  import androidx.core.app.NotificationCompat
14
14
  import androidx.core.content.ContextCompat
15
+ import {{PACKAGE_NAME}}.R
15
16
  import com.google.android.gms.location.LocationCallback
16
17
  import com.google.android.gms.location.LocationRequest
17
18
  import com.google.android.gms.location.LocationResult
@@ -21,71 +22,246 @@ import org.json.JSONArray
21
22
  import org.json.JSONObject
22
23
  import java.io.File
23
24
 
25
+ class CraftRecordingException(val code: String, message: String) : IllegalStateException(message)
26
+
24
27
  object CraftLocationRecordingStore {
25
28
  private const val PREFS = "craft_location_recording"
26
29
  private const val FILE = "craft-location-recording.jsonl"
27
-
28
30
  private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
29
31
  private fun file(context: Context) = File(context.filesDir, FILE)
32
+ private fun archive(context: Context, id: String): File {
33
+ val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8))
34
+ return File(context.filesDir, "craft-location-completed-${digest.joinToString("") { "%02x".format(it) }}.json")
35
+ }
36
+ private fun acknowledged(context: Context, id: String): Boolean {
37
+ val marker = File(archive(context, id).path.replace("-completed-", "-ack-"))
38
+ if (!marker.exists()) return false
39
+ if (marker.readText() != id) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid recording acknowledgement")
40
+ return true
41
+ }
42
+ private fun writeAtomic(destination: File, value: String) {
43
+ val temporary = File.createTempFile(".craft-recording-", ".tmp", destination.parentFile)
44
+ try {
45
+ java.io.FileOutputStream(temporary).use { stream -> stream.write(value.toByteArray(Charsets.UTF_8)); stream.fd.sync() }
46
+ java.nio.file.Files.move(temporary.toPath(), destination.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
47
+ } finally { temporary.delete() }
48
+ }
49
+ private fun expected(options: JSONObject?): String? {
50
+ if (options == null || !options.has("expectedId")) return null
51
+ val id = options.opt("expectedId")
52
+ if (id !is String || id.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "expectedId must be a nonempty recording ID")
53
+ return id
54
+ }
55
+ private fun requireOwner(context: Context, options: JSONObject?) {
56
+ val id = expected(options) ?: return
57
+ if (id != prefs(context).getString("id", null)) throw CraftRecordingException("RECORDING_CONFLICT", "The active location recording has changed")
58
+ }
59
+ private fun completed(context: Context, id: String?): JSONObject? {
60
+ if (id == null) return null
61
+ val source = archive(context, id)
62
+ if (acknowledged(context, id)) {
63
+ if (source.exists() && !source.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged recording")
64
+ throw CraftRecordingException("RECORDING_NOT_FOUND", "This recording has been acknowledged")
65
+ }
66
+ if (!source.exists()) return null
67
+ val saved = JSONObject(source.readText())
68
+ if (saved.optString("id") != id || saved.optBoolean("active") || saved.optJSONArray("locations") == null)
69
+ throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid completed recording archive")
70
+ return saved
71
+ }
72
+ private fun archiveCompleted(context: Context) {
73
+ val id = prefs(context).getString("id", null) ?: return
74
+ if (acknowledged(context, id) || completed(context, id) != null) return
75
+ val saved = state(context, includeLocations = true)
76
+ val destination = archive(context, id)
77
+ writeAtomic(destination, saved.toString())
78
+ }
79
+ private fun edit(context: Context, update: (android.content.SharedPreferences.Editor) -> Unit) {
80
+ val values = prefs(context)
81
+ val previous = values.all.toMap()
82
+ val editor = values.edit()
83
+ update(editor)
84
+ if (editor.commit()) return
85
+ // SharedPreferences publishes its memory map before reporting disk
86
+ // failure. Restore it too, so a rejected operation cannot change owner.
87
+ val rollback = values.edit().clear()
88
+ previous.forEach { (key, value) -> when (value) {
89
+ is String -> rollback.putString(key, value)
90
+ is Boolean -> rollback.putBoolean(key, value)
91
+ is Long -> rollback.putLong(key, value)
92
+ is Int -> rollback.putInt(key, value)
93
+ is Float -> rollback.putFloat(key, value)
94
+ else -> throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Invalid recording metadata")
95
+ } }
96
+ rollback.commit()
97
+ throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to persist recording state")
98
+ }
99
+ private fun commit(context: Context, active: Boolean, paused: Boolean) {
100
+ edit(context) { it.putBoolean("active", active).putBoolean("paused", paused) }
101
+ }
102
+
103
+ @Synchronized
104
+ fun recover(context: Context) {
105
+ val id = recordingId(context) ?: return
106
+ if (!acknowledged(context, id)) return
107
+ val saved = archive(context, id)
108
+ if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged recording")
109
+ if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged samples")
110
+ edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
111
+ listener = null
112
+ }
30
113
 
31
114
  @Synchronized
32
115
  fun start(context: Context, id: String, startedAt: Long) {
116
+ recover(context)
117
+ if (isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "A location recording is already active")
118
+ if (id == recordingId(context) || archive(context, id).exists() || acknowledged(context, id))
119
+ throw CraftRecordingException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
120
+ archiveCompleted(context)
33
121
  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()
122
+ edit(context) { it.putString("id", id).putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt) }
40
123
  }
41
124
 
42
125
  @Synchronized
43
- fun setPaused(context: Context, paused: Boolean) {
44
- prefs(context).edit().putBoolean("paused", paused).apply()
126
+ fun updatePause(context: Context, paused: Boolean, options: JSONObject?): JSONObject {
127
+ requireOwner(context, options)
128
+ if (!isActive(context)) throw CraftRecordingException("NO_ACTIVE_RECORDING", "No active location recording")
129
+ commit(context, true, paused)
130
+ return state(context)
45
131
  }
132
+ @Synchronized fun setPaused(context: Context, paused: Boolean) { updatePause(context, paused, null) }
46
133
 
47
134
  @Synchronized
48
- fun stop(context: Context) {
49
- prefs(context).edit().putBoolean("active", false).putBoolean("paused", false).apply()
135
+ fun finish(context: Context, options: JSONObject?): JSONObject {
136
+ completed(context, expected(options))?.let { return it }
137
+ requireOwner(context, options)
138
+ val stopped = state(context, includeLocations = true)
139
+ commit(context, false, false)
140
+ listener = null
141
+ return stopped.put("active", false).put("paused", false)
142
+ }
143
+ @Synchronized fun stop(context: Context) { finish(context, null) }
144
+ @Synchronized
145
+ fun acknowledge(context: Context, options: JSONObject?): Boolean {
146
+ val id = expected(options) ?: throw CraftRecordingException("INVALID_ARGUMENT", "Acknowledgement needs expectedId")
147
+ val current = recordingId(context) == id
148
+ if (current && isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "The recording is still active")
149
+ if (!current && !acknowledged(context, id) && completed(context, id) == null)
150
+ throw CraftRecordingException("RECORDING_CONFLICT", "Unknown recording ID")
151
+ val saved = archive(context, id)
152
+ val marker = File(saved.path.replace("-completed-", "-ack-"))
153
+ if (!marker.exists()) writeAtomic(marker, id)
154
+ if (saved.exists() && !saved.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove completed recording")
155
+ if (current) {
156
+ if (file(context).exists() && !file(context).delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove recording samples")
157
+ edit(context) { it.remove("id").remove("startedAt").putBoolean("active", false).putBoolean("paused", false) }
158
+ listener = null
159
+ }
160
+ return true
50
161
  }
51
162
 
52
- fun isActive(context: Context) = prefs(context).getBoolean("active", false)
53
- fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
163
+ /** One native backend used by the STX host and the page's JNI entry point. */
164
+ @JvmStatic
165
+ fun perform(context: Context, method: String, optionsJson: String): String = synchronized(this) {
166
+ return try {
167
+ recover(context)
168
+ val options = JSONObject(optionsJson)
169
+ val result: Any = when (method) {
170
+ "startRecording" -> {
171
+ val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
172
+ if (!permitted) throw CraftRecordingException("PERMISSION_DENIED", "Location is not allowed")
173
+ val id = if (options.has("recordingId")) {
174
+ val requested = options.opt("recordingId")
175
+ if (requested !is String || requested.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "recordingId must be a nonempty recording ID")
176
+ requested
177
+ } else java.util.UUID.randomUUID().toString()
178
+ start(context, id, System.currentTimeMillis())
179
+ try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", id)) }
180
+ catch (error: Exception) { finish(context, JSONObject().put("expectedId", id)); throw error }
181
+ state(context)
182
+ }
183
+ "pauseRecording" -> updatePause(context, true, options)
184
+ "resumeRecording" -> {
185
+ val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
186
+ if (!permitted) throw CraftRecordingException("PERMISSION_DENIED", "Location is not allowed")
187
+ val wasPaused = isPaused(context)
188
+ val resumed = updatePause(context, false, options)
189
+ try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", recordingId(context))) }
190
+ catch (error: Exception) { updatePause(context, wasPaused, options); throw error }
191
+ resumed
192
+ }
193
+ "stopRecording" -> {
194
+ val stopped = finish(context, options)
195
+ if (stopped.optString("id") == recordingId(context) && !isActive(context))
196
+ context.startService(Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP).putExtra("recordingId", stopped.optString("id")))
197
+ stopped
198
+ }
199
+ "getRecordingState" -> state(context, options = options)
200
+ "readRecording" -> read(context, options)
201
+ "acknowledgeRecording" -> acknowledge(context, options)
202
+ else -> throw CraftRecordingException("UNKNOWN_ACTION", "Unsupported Location method")
203
+ }
204
+ JSONObject().put("ok", true).put("data", result).toString()
205
+ } catch (error: Exception) {
206
+ JSONObject().put("ok", false).put("code", (error as? CraftRecordingException)?.code ?: "RECORDING_STORAGE_ERROR").put("message", error.message ?: "Recording failed").toString()
207
+ }
208
+ }
54
209
 
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
210
+ @Synchronized fun isActive(context: Context) = prefs(context).getBoolean("active", false)
211
+ @Synchronized fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
212
+ @Synchronized fun recordingId(context: Context): String? = prefs(context).getString("id", null)
213
+ @Volatile var listener: ((android.location.Location, String) -> Unit)? = null
214
+ @Volatile var nativeListener: ((android.location.Location, String) -> Unit)? = null
60
215
 
61
216
  @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)
217
+ fun listen(context: Context, id: String, callback: (android.location.Location, String) -> Unit) {
218
+ if (recordingId(context) == id && isActive(context)) listener = callback
219
+ }
220
+
221
+ @JvmOverloads
222
+ fun append(context: Context, location: android.location.Location, expectedId: String? = null) {
223
+ val notify = synchronized(this) {
224
+ val id = recordingId(context) ?: return
225
+ if (!isActive(context) || isPaused(context) || (expectedId != null && expectedId != id)) return
226
+ val json = JSONObject().apply {
227
+ put("latitude", location.latitude); put("longitude", location.longitude)
228
+ put("accuracy", location.accuracy); put("altitude", location.altitude)
229
+ put("speed", location.speed); put("heading", location.bearing)
230
+ put("timestamp", location.time); put("recordingId", recordingId(context))
231
+ }
232
+ file(context).appendText("$json\n")
233
+ Triple(listener, nativeListener, id)
73
234
  }
74
- file(context).appendText("$json\n")
235
+ // The event keeps the owner captured before releasing the store lock.
236
+ notify.first?.invoke(location, notify.third)
237
+ notify.second?.invoke(location, notify.third)
75
238
  }
76
239
 
77
240
  @Synchronized
78
241
  fun locations(context: Context): JSONArray {
79
242
  val result = JSONArray()
80
243
  val source = file(context)
81
- if (!source.exists()) return result
82
- source.forEachLine { line ->
83
- if (line.isNotBlank()) runCatching { result.put(JSONObject(line)) }
244
+ if (!source.exists()) {
245
+ val id = recordingId(context)
246
+ if (id != null && !acknowledged(context, id)) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Recording samples are missing")
247
+ return result
84
248
  }
249
+ source.forEachLine { line -> if (line.isNotBlank()) runCatching { result.put(JSONObject(line)) } }
85
250
  return result
86
251
  }
87
-
88
- fun state(context: Context, includeLocations: Boolean = false): JSONObject {
252
+ @Synchronized
253
+ fun read(context: Context, options: JSONObject?): JSONArray {
254
+ completed(context, expected(options))?.let { return it.getJSONArray("locations") }
255
+ requireOwner(context, options)
256
+ return locations(context)
257
+ }
258
+ @Synchronized
259
+ fun state(context: Context, includeLocations: Boolean = false, options: JSONObject? = null): JSONObject {
260
+ completed(context, expected(options))?.let {
261
+ if (!includeLocations) it.remove("locations")
262
+ return it
263
+ }
264
+ requireOwner(context, options)
89
265
  val values = prefs(context)
90
266
  val locations = locations(context)
91
267
  return JSONObject().apply {
@@ -102,6 +278,8 @@ object CraftLocationRecordingStore {
102
278
  class LocationRecordingService : Service() {
103
279
  private val client by lazy { LocationServices.getFusedLocationProviderClient(this) }
104
280
  private var callback: LocationCallback? = null
281
+ private var callbackRecordingId: String? = null
282
+ private var serviceStartId = 0
105
283
 
106
284
  override fun onCreate() {
107
285
  super.onCreate()
@@ -110,7 +288,8 @@ class LocationRecordingService : Service() {
110
288
  manager.createNotificationChannel(NotificationChannel(CHANNEL, "Activity recording", NotificationManager.IMPORTANCE_LOW))
111
289
  }
112
290
  val notification = NotificationCompat.Builder(this, CHANNEL)
113
- .setSmallIcon(android.R.drawable.ic_menu_mylocation)
291
+ .setSmallIcon(R.drawable.craft_notification_icon)
292
+ .setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
114
293
  .setContentTitle("Recording your activity")
115
294
  .setContentText("${applicationInfo.loadLabel(packageManager)} is securely saving your route")
116
295
  .setOngoing(true)
@@ -120,28 +299,52 @@ class LocationRecordingService : Service() {
120
299
  }
121
300
 
122
301
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
302
+ return synchronized(CraftLocationRecordingStore) {
303
+ try { CraftLocationRecordingStore.recover(this) }
304
+ catch (error: Exception) {
305
+ android.util.Log.e("CraftLocation", "Unable to restore recording", error)
306
+ stopUpdates()
307
+ stopSelfResult(startId)
308
+ return START_NOT_STICKY
309
+ }
310
+ val expected = intent?.getStringExtra("recordingId")
311
+ val current = CraftLocationRecordingStore.recordingId(this)
312
+ if (expected != null && expected != current) {
313
+ // ACK can clear A before its queued service intent arrives. Only
314
+ // tear down an unowned service; a newer B must keep its callback.
315
+ if (current == null && !CraftLocationRecordingStore.isActive(this)) {
316
+ stopUpdates()
317
+ stopSelfResult(startId)
318
+ return START_NOT_STICKY
319
+ }
320
+ return START_STICKY
321
+ }
322
+ serviceStartId = startId
123
323
  when (intent?.action) {
124
324
  ACTION_STOP -> {
125
325
  stopUpdates()
126
- stopSelf()
326
+ stopSelfResult(startId)
127
327
  return START_NOT_STICKY
128
328
  }
129
329
  else -> if (!startUpdates()) {
130
- stopSelf()
330
+ stopSelfResult(startId)
131
331
  return START_NOT_STICKY
132
332
  }
133
333
  }
134
- return START_STICKY
334
+ START_STICKY
335
+ }
135
336
  }
136
337
 
137
338
  private fun startUpdates(): Boolean {
138
- if (callback != null) return true
339
+ val id = CraftLocationRecordingStore.recordingId(this) ?: return false
340
+ if (callback != null && callbackRecordingId == id) return true
341
+ stopUpdates()
139
342
  if (!CraftLocationRecordingStore.isActive(this)) return false
140
343
  val hasForegroundLocation =
141
344
  ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
142
345
  ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
143
346
  if (!hasForegroundLocation) {
144
- CraftLocationRecordingStore.stop(this)
347
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
145
348
  return false
146
349
  }
147
350
  val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000)
@@ -150,23 +353,27 @@ class LocationRecordingService : Service() {
150
353
  .build()
151
354
  val nextCallback = object : LocationCallback() {
152
355
  override fun onLocationResult(result: LocationResult) {
153
- for (location in result.locations) CraftLocationRecordingStore.append(this@LocationRecordingService, location)
356
+ for (location in result.locations) CraftLocationRecordingStore.append(this@LocationRecordingService, location, id)
154
357
  }
155
358
  }
156
359
  callback = nextCallback
360
+ callbackRecordingId = id
157
361
  return try {
158
362
  client.requestLocationUpdates(request, nextCallback, Looper.getMainLooper())
159
363
  .addOnFailureListener {
160
- if (callback === nextCallback) {
161
- CraftLocationRecordingStore.stop(this)
162
- stopUpdates()
163
- stopSelf()
364
+ synchronized(CraftLocationRecordingStore) {
365
+ if (callback === nextCallback && CraftLocationRecordingStore.recordingId(this) == id) {
366
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
367
+ .onFailure { android.util.Log.e("CraftLocation", "Unable to persist failed recording", it) }
368
+ stopUpdates()
369
+ stopSelfResult(serviceStartId)
370
+ }
164
371
  }
165
372
  }
166
373
  true
167
374
  } catch (error: Exception) {
168
375
  callback = null
169
- CraftLocationRecordingStore.stop(this)
376
+ runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
170
377
  false
171
378
  }
172
379
  }
@@ -174,6 +381,7 @@ class LocationRecordingService : Service() {
174
381
  private fun stopUpdates() {
175
382
  callback?.let { client.removeLocationUpdates(it) }
176
383
  callback = null
384
+ callbackRecordingId = null
177
385
  }
178
386
 
179
387
  override fun onDestroy() {