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.
- package/README.md +71 -0
- package/dist/android/src/emulator.d.ts +23 -0
- package/dist/android/src/index.d.ts +24 -2
- package/dist/android/src/index.js +362 -103
- package/dist/android/src/native-map.d.ts +7 -0
- package/dist/android/templates/CraftBridge.kt.template +67 -55
- package/dist/android/templates/CraftNative.kt.template +27 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
- package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
- package/dist/android/templates/CraftNativeServices.kt.template +45 -35
- package/dist/android/templates/CraftNativeShell.kt.template +103 -20
- package/dist/android/templates/LocationRecordingService.kt.template +255 -47
- package/dist/android/templates/MainActivityNative.kt.template +7 -428
- package/dist/android/templates/build.gradle.kts.app.template +1 -1
- package/dist/android/templates/build.gradle.kts.project.template +1 -1
- package/dist/android-adb.d.ts +2 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/mobile.d.ts +49 -14
- package/dist/browser/storage.d.ts +3 -0
- package/dist/browser.js +163 -51
- package/dist/cli.js +1 -1
- package/dist/index.cjs +126 -12
- package/dist/index.js +126 -12
- package/dist/ios/src/index.d.ts +5 -0
- package/dist/ios/src/index.js +17 -1
- package/dist/ios/src/testing.d.ts +16 -2
- package/dist/ios/templates/CraftApp.swift +418 -93
- package/dist/ios/templates/CraftHeartRate.swift +141 -44
- package/dist/ios/templates/CraftNativeActions.swift +46 -48
- package/dist/ios/templates/CraftNativeScreen.swift +82 -17
- package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
- package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +45 -1
- package/dist/macos/templates/native_layout.zig +41 -5
- package/dist/mobile-testing.js +30 -4
- package/dist/mobile.js +169 -17
- 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(
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
926
|
-
val pulse =
|
|
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(
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@Volatile var
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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())
|
|
82
|
-
|
|
83
|
-
if (
|
|
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
|
|
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(
|
|
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
|
-
|
|
326
|
+
stopSelfResult(startId)
|
|
127
327
|
return START_NOT_STICKY
|
|
128
328
|
}
|
|
129
329
|
else -> if (!startUpdates()) {
|
|
130
|
-
|
|
330
|
+
stopSelfResult(startId)
|
|
131
331
|
return START_NOT_STICKY
|
|
132
332
|
}
|
|
133
333
|
}
|
|
134
|
-
|
|
334
|
+
START_STICKY
|
|
335
|
+
}
|
|
135
336
|
}
|
|
136
337
|
|
|
137
338
|
private fun startUpdates(): Boolean {
|
|
138
|
-
|
|
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.
|
|
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
|
-
|
|
161
|
-
CraftLocationRecordingStore.
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
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() {
|