craft-native 0.0.236 → 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 (34) 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 +55 -6
  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/templates/MainActivityNative.kt.template +163 -5
  12. package/dist/android-adb.d.ts +2 -0
  13. package/dist/api/index.d.ts +1 -1
  14. package/dist/api/mobile.d.ts +49 -14
  15. package/dist/browser/storage.d.ts +3 -0
  16. package/dist/browser.js +163 -51
  17. package/dist/cli.js +1 -1
  18. package/dist/components/index.d.ts +33 -0
  19. package/dist/index.cjs +126 -12
  20. package/dist/index.js +126 -12
  21. package/dist/ios/src/app-shortcuts.d.ts +11 -0
  22. package/dist/ios/src/index.d.ts +7 -0
  23. package/dist/ios/src/index.js +65 -1
  24. package/dist/ios/src/testing.d.ts +2 -0
  25. package/dist/ios/templates/CraftApp.swift +392 -88
  26. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  27. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  28. package/dist/ios/templates/CraftNativeScreen.swift +177 -10
  29. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  30. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  31. package/dist/ios/templates/testing/DriverTests.swift.template +8 -1
  32. package/dist/mobile-testing.js +30 -4
  33. package/dist/mobile.js +169 -17
  34. package/package.json +1 -1
@@ -410,6 +410,9 @@ class CraftBridge(
410
410
  pushNotifications: {{ENABLE_PUSH}},
411
411
  secureStorage: {{ENABLE_SECURE_STORAGE}},
412
412
  geolocation: {{ENABLE_GEOLOCATION}},
413
+ crypto: true,
414
+ locationRecordingOwnership: {{ENABLE_GEOLOCATION}} && ${CraftNative.supportsRecordingOwnership()},
415
+ locationRecordingIntentIdentity: {{ENABLE_GEOLOCATION}} && ${CraftNative.supportsRecordingIntentIdentity()},
413
416
  backgroundLocation: {{ENABLE_BACKGROUND_LOCATION}},
414
417
  clipboard: false,
415
418
  deviceInfo: true,
@@ -1769,16 +1772,41 @@ class CraftBridge(
1769
1772
  return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.crashReporterContext()); });
1770
1773
  }
1771
1774
  };
1775
+ function recordingCall(method, options) {
1776
+ return Promise.resolve().then(function() {
1777
+ if (options && Object.prototype.hasOwnProperty.call(options, 'expectedId') &&
1778
+ (typeof options.expectedId !== 'string' || !options.expectedId.trim())) {
1779
+ var invalid = new Error('expectedId must be a nonempty recording ID');
1780
+ invalid.code = 'INVALID_ARGUMENT';
1781
+ throw invalid;
1782
+ }
1783
+ if (options && Object.prototype.hasOwnProperty.call(options, 'recordingId') &&
1784
+ (typeof options.recordingId !== 'string' || !options.recordingId.trim())) {
1785
+ var invalidStart = new Error('recordingId must be a nonempty recording ID');
1786
+ invalidStart.code = 'INVALID_ARGUMENT';
1787
+ throw invalidStart;
1788
+ }
1789
+ var result = JSON.parse(CraftAndroid.recordingAction(method, JSON.stringify(options || {})));
1790
+ if (!result.ok) {
1791
+ var error = new Error(result.message || 'Recording failed');
1792
+ error.code = result.code;
1793
+ throw error;
1794
+ }
1795
+ return result.data;
1796
+ });
1797
+ }
1798
+ craft.crypto = { randomUUID: function() { return Promise.resolve().then(function() { return CraftAndroid.randomUUID(); }); } };
1772
1799
  craft.location = {
1773
1800
  getCurrentPosition: craft.getCurrentPosition,
1774
1801
  watchPosition: craft.watchPosition,
1775
1802
  clearWatch: craft.clearWatch,
1776
- startRecording: function(options) { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.startLocationRecording(JSON.stringify(options || {}))); }); },
1777
- pauseRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.pauseLocationRecording()); }); },
1778
- resumeRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.resumeLocationRecording()); }); },
1779
- stopRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.stopLocationRecording()); }); },
1780
- getRecordingState: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.getLocationRecordingState()); }); },
1781
- readRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.readLocationRecording()); }); }
1803
+ startRecording: function(options) { return recordingCall('startRecording', options); },
1804
+ pauseRecording: function(options) { return recordingCall('pauseRecording', options); },
1805
+ resumeRecording: function(options) { return recordingCall('resumeRecording', options); },
1806
+ stopRecording: function(options) { return recordingCall('stopRecording', options); },
1807
+ getRecordingState: function(options) { return recordingCall('getRecordingState', options); },
1808
+ readRecording: function(options) { return recordingCall('readRecording', options); },
1809
+ acknowledgeRecording: function(options) { return recordingCall('acknowledgeRecording', options); }
1782
1810
  };
1783
1811
  var shareApi = function(text, title) { return legacyShare(text, title); };
1784
1812
  shareApi.share = function(options) {
@@ -1840,6 +1868,9 @@ class CraftBridge(
1840
1868
  navigationGeneration += 1
1841
1869
  }
1842
1870
 
1871
+ @JavascriptInterface
1872
+ fun randomUUID(): String = CraftNative.randomUUID() ?: java.util.UUID.randomUUID().toString()
1873
+
1843
1874
  // ==================== Haptics ====================
1844
1875
 
1845
1876
  /**
@@ -2738,75 +2769,55 @@ class CraftBridge(
2738
2769
  }
2739
2770
 
2740
2771
  @JavascriptInterface
2741
- fun startLocationRecording(optionsJson: String): String {
2742
- // Null is "Zig declined". A permission-denied answer is not null — it
2743
- // is a state object saying so, and returning it here is the answer.
2744
- CraftNative.startLocationRecording(activity)?.let { return it }
2772
+ fun recordingAction(method: String, optionsJson: String): String {
2773
+ if (!{{ENABLE_GEOLOCATION}}) return JSONObject().put("ok", false).put("code", "CAPABILITY_DISABLED").put("message", "Geolocation is disabled").toString()
2774
+ val result = CraftNative.recordingAction(activity, method, optionsJson)
2775
+ ?: CraftLocationRecordingStore.perform(activity, method, optionsJson)
2776
+ if (method == "startRecording" && JSONObject(result).optBoolean("ok")) {
2777
+ CraftLocationRecordingStore.listen(activity, JSONObject(result).getJSONObject("data").getString("id")) { location, id ->
2778
+ val detail = mapOf<String, Any>("latitude" to location.latitude, "longitude" to location.longitude,
2779
+ "accuracy" to location.accuracy, "altitude" to location.altitude, "speed" to location.speed,
2780
+ "heading" to location.bearing, "timestamp" to location.time, "recordingId" to id)
2781
+ sendEvent("craftLocationUpdate", detail)
2782
+ }
2783
+ }
2784
+ return result
2785
+ }
2745
2786
 
2746
- if (!hasForegroundLocationPermission()) {
2747
- return JSONObject().apply {
2748
- put("id", JSONObject.NULL)
2749
- put("active", false)
2750
- put("paused", false)
2751
- put("startedAt", JSONObject.NULL)
2752
- put("sampleCount", 0)
2753
- }.toString()
2754
- }
2755
- val id = UUID.randomUUID().toString()
2756
- CraftLocationRecordingStore.start(activity, id, System.currentTimeMillis())
2757
- ContextCompat.startForegroundService(
2758
- activity,
2759
- Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START)
2760
- )
2761
- return CraftLocationRecordingStore.state(activity).toString()
2787
+ private fun legacyRecordingAction(method: String, optionsJson: String = "{}"): String {
2788
+ val answer = JSONObject(recordingAction(method, optionsJson))
2789
+ if (!answer.optBoolean("ok")) throw com.craft.runtime.CraftRecordingException(answer.optString("code"), answer.optString("message"))
2790
+ return answer.get("data").toString()
2762
2791
  }
2763
2792
 
2764
2793
  @JavascriptInterface
2765
- fun pauseLocationRecording(): String {
2766
- CraftNative.pauseLocationRecording(activity)?.let { return it }
2794
+ fun startLocationRecording(optionsJson: String): String {
2795
+ return legacyRecordingAction("startRecording", optionsJson)
2796
+ }
2767
2797
 
2768
- CraftLocationRecordingStore.setPaused(activity, true)
2769
- return CraftLocationRecordingStore.state(activity).toString()
2798
+ @JavascriptInterface
2799
+ fun pauseLocationRecording(): String {
2800
+ return legacyRecordingAction("pauseRecording")
2770
2801
  }
2771
2802
 
2772
2803
  @JavascriptInterface
2773
2804
  fun resumeLocationRecording(): String {
2774
- CraftNative.resumeLocationRecording(activity)?.let { return it }
2775
-
2776
- CraftLocationRecordingStore.setPaused(activity, false)
2777
- if (CraftLocationRecordingStore.isActive(activity)) {
2778
- ContextCompat.startForegroundService(
2779
- activity,
2780
- Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START)
2781
- )
2782
- }
2783
- return CraftLocationRecordingStore.state(activity).toString()
2805
+ return legacyRecordingAction("resumeRecording")
2784
2806
  }
2785
2807
 
2786
2808
  @JavascriptInterface
2787
2809
  fun stopLocationRecording(): String {
2788
- CraftNative.stopLocationRecording(activity)?.let { return it }
2789
-
2790
- CraftLocationRecordingStore.stop(activity)
2791
- val result = CraftLocationRecordingStore.state(activity, includeLocations = true).toString()
2792
- activity.stopService(Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP))
2793
- return result
2810
+ return legacyRecordingAction("stopRecording")
2794
2811
  }
2795
2812
 
2796
2813
  @JavascriptInterface
2797
2814
  fun getLocationRecordingState(): String {
2798
- // Null is "Zig declined", not "no recording" — an absent recording is
2799
- // a state object saying so, and returning it here is the answer.
2800
- CraftNative.getLocationRecordingState(activity)?.let { return it }
2801
-
2802
- return CraftLocationRecordingStore.state(activity).toString()
2815
+ return legacyRecordingAction("getRecordingState")
2803
2816
  }
2804
2817
 
2805
2818
  @JavascriptInterface
2806
2819
  fun readLocationRecording(): String {
2807
- CraftNative.readLocationRecording(activity)?.let { return it }
2808
-
2809
- return CraftLocationRecordingStore.locations(activity).toString()
2820
+ return legacyRecordingAction("readRecording")
2810
2821
  }
2811
2822
 
2812
2823
  // ==================== Clipboard ====================
@@ -230,6 +230,7 @@ object CraftNative {
230
230
  }
231
231
 
232
232
  private external fun nativeGetDeviceInfo(activity: Activity): String?
233
+ private external fun nativeRandomUUID(): String?
233
234
  private external fun nativeGetMemoryUsage(): String?
234
235
  private external fun nativeLog(message: String): Boolean
235
236
  private external fun nativeFlexRow(
@@ -437,6 +438,8 @@ object CraftNative {
437
438
  private external fun nativeSetKeepAwake(activity: Activity, enabled: Boolean): Boolean
438
439
  private external fun nativeDownloadFile(activity: Activity, url: String, filename: String): Boolean
439
440
  private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean
441
+ private external fun nativeRecordingAction(activity: Activity, method: String, options: String): String?
442
+ private external fun nativeRecordingOwnershipVersion(): Int
440
443
  private external fun nativeGetLocationRecordingState(activity: Activity): String?
441
444
  private external fun nativeReadLocationRecording(activity: Activity): String?
442
445
  private external fun nativeStartLocationRecording(activity: Activity): String?
@@ -487,6 +490,11 @@ object CraftNative {
487
490
  }
488
491
  }
489
492
 
493
+ fun randomUUID(): String? {
494
+ if (!isAvailable) return null
495
+ return try { nativeRandomUUID() } catch (_: UnsatisfiedLinkError) { null }
496
+ }
497
+
490
498
  /** The JVM heap as JSON, or null to use the Kotlin implementation. */
491
499
  fun getMemoryUsage(): String? {
492
500
  if (!isAvailable) return null
@@ -1921,6 +1929,25 @@ object CraftNative {
1921
1929
  * The read side of the recording store. Null is "Zig declined", not "no
1922
1930
  * recording" — an absent recording is a state object saying so.
1923
1931
  */
1932
+ fun supportsRecordingOwnership(): Boolean {
1933
+ if (!isAvailable) return true
1934
+ return try { nativeRecordingOwnershipVersion() >= 1 } catch (_: UnsatisfiedLinkError) { false }
1935
+ }
1936
+
1937
+ fun supportsRecordingIntentIdentity(): Boolean {
1938
+ if (!isAvailable) return true
1939
+ return try { nativeRecordingOwnershipVersion() >= 2 } catch (_: UnsatisfiedLinkError) { false }
1940
+ }
1941
+
1942
+ fun recordingAction(activity: Activity, method: String, options: String): String? {
1943
+ if (!isAvailable) return null
1944
+ return try { nativeRecordingAction(activity, method, options)
1945
+ ?: org.json.JSONObject().put("ok", false).put("code", "RECORDING_OWNERSHIP_UNSUPPORTED").put("message", "The linked runtime did not serve the recorder request").toString() }
1946
+ catch (error: UnsatisfiedLinkError) {
1947
+ org.json.JSONObject().put("ok", false).put("code", "RECORDING_OWNERSHIP_UNSUPPORTED").put("message", "The linked runtime lacks the shared recorder API").toString()
1948
+ }
1949
+ }
1950
+
1924
1951
  fun getLocationRecordingState(activity: Activity): String? {
1925
1952
  if (!isAvailable) return null
1926
1953
  return try {
@@ -43,6 +43,7 @@ import kotlin.math.roundToInt
43
43
  private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
44
44
  private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
45
45
  private const val CRAFT_NATIVE_FILE_PICK_REQUEST = 4101
46
+ private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
46
47
  private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
47
48
  private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
48
49
  private const val CRAFT_NATIVE_FILE_DEFAULT_QUALITY = 0.85
@@ -88,6 +89,7 @@ internal class CraftNativeCapabilities(
88
89
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
89
90
  private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
90
91
  private var pendingFilePick: PendingFilePick? = null
92
+ private var pendingNotificationPermission: ((Boolean) -> Unit)? = null
91
93
  private var database: SQLiteDatabase? = null
92
94
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
93
95
 
@@ -114,6 +116,9 @@ internal class CraftNativeCapabilities(
114
116
 
115
117
  fun names(): JSONArray = JSONArray().apply {
116
118
  listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "files").forEach(::put)
119
+ put("storageCompareAndSet")
120
+ put("storageSetIfAbsent")
121
+ put("crypto")
117
122
  if (config.optBoolean("enableBluetooth")) put("bluetooth")
118
123
  if (config.optBoolean("enableBiometric")) put("biometric")
119
124
  if (config.optBoolean("enableSecureStorage")) put("secureStorage")
@@ -136,11 +141,19 @@ internal class CraftNativeCapabilities(
136
141
  return
137
142
  }
138
143
  when (module) {
144
+ "Crypto" -> {
145
+ if (method == "randomUUID") complete(requestToken, success(UUID.randomUUID().toString()), completion)
146
+ else complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Crypto method"), completion)
147
+ }
139
148
  "Bluetooth" -> {
140
149
  if (!config.optBoolean("enableBluetooth")) complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
141
150
  else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
142
151
  }
143
- "Storage" -> complete(requestToken, storage(method, args), completion)
152
+ "Storage" -> mainHandler.post {
153
+ val answer = runCatching { storage(method, args) }
154
+ .getOrElse { failure("STORAGE_ERROR", it.message ?: "Storage operation failed") }
155
+ complete(requestToken, answer, completion)
156
+ }
144
157
  "SecureStorage" -> {
145
158
  if (!config.optBoolean("enableSecureStorage")) complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
146
159
  else complete(requestToken, secureStorage(method, args), completion)
@@ -646,9 +659,9 @@ internal class CraftNativeCapabilities(
646
659
  }
647
660
  }
648
661
 
649
- private fun storage(method: String, args: JSONArray): CraftNativeCapabilityResult {
662
+ private fun storage(method: String, args: JSONArray): CraftNativeCapabilityResult = synchronized(CraftNativeStorage.lock) {
650
663
  if (method == "clear") {
651
- storage.edit().clear().commit()
664
+ if (!CraftNativeStorage.commit(storage) { it.clear() }) return failure("STORAGE_ERROR", "Storage could not be cleared")
652
665
  storeObserver?.invoke("storage", null, null)
653
666
  return success(true)
654
667
  }
@@ -664,13 +677,33 @@ internal class CraftNativeCapabilities(
664
677
  if (args.length() != 2) return failure("INVALID_ARGUMENT", "Storage.set needs a key and value")
665
678
  val value = jsonString(args.opt(1))
666
679
  ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
667
- if (storage.edit().putString(key, value).commit()) {
680
+ if (CraftNativeStorage.commit(storage) { it.putString(key, value) }) {
668
681
  storeObserver?.invoke("storage", key, value)
669
682
  success(true)
670
683
  }
671
684
  else failure("STORAGE_ERROR", "Storage could not persist the value")
672
685
  }
673
- "remove" -> success(storage.edit().remove(key).commit().also { storeObserver?.invoke("storage", key, null) })
686
+ "compareAndSet" -> {
687
+ if (args.length() != 3) return failure("INVALID_ARGUMENT", "Storage.compareAndSet needs a key, expected value and next value")
688
+ val current = storage.getString(key, null) ?: return success(false)
689
+ if (!CraftNativeStorage.jsonEqual(JSONTokener(current).nextValue(), args.opt(1))) return success(false)
690
+ val next = jsonString(args.opt(2)) ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
691
+ if (!CraftNativeStorage.commit(storage) { it.putString(key, next) }) return failure("STORAGE_ERROR", "Storage could not persist the checkpoint")
692
+ storeObserver?.invoke("storage", key, next)
693
+ success(true)
694
+ }
695
+ "setIfAbsent" -> {
696
+ if (args.length() != 2) return failure("INVALID_ARGUMENT", "Storage.setIfAbsent needs a key and value")
697
+ if (storage.contains(key)) return success(false)
698
+ val value = jsonString(args.opt(1)) ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
699
+ if (!CraftNativeStorage.commit(storage) { it.putString(key, value) }) return failure("STORAGE_ERROR", "Storage could not persist the new intent")
700
+ storeObserver?.invoke("storage", key, value)
701
+ success(true)
702
+ }
703
+ "remove" -> if (CraftNativeStorage.commit(storage) { it.remove(key) }) {
704
+ storeObserver?.invoke("storage", key, null)
705
+ success(true)
706
+ } else failure("STORAGE_ERROR", "Storage could not remove the value")
674
707
  else -> failure("UNKNOWN_ACTION", "Unsupported Storage method")
675
708
  }
676
709
  }
@@ -891,7 +924,16 @@ internal class CraftNativeCapabilities(
891
924
  return
892
925
  }
893
926
  when (method) {
894
- "schedule" -> complete(requestToken, schedule(args.optJSONObject(0)), completion)
927
+ "schedule" -> {
928
+ val notification = args.optJSONObject(0)
929
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
930
+ val host = activity
931
+ if (host == null) { complete(requestToken, failure("PERMISSION_DENIED", "Notification permission must be requested while the app is open"), completion); return }
932
+ if (pendingNotificationPermission != null) { complete(requestToken, failure("BUSY", "A notification permission request is already open"), completion); return }
933
+ pendingNotificationPermission = { granted -> complete(requestToken, if (granted) schedule(notification) else failure("PERMISSION_DENIED", "Notification permission denied"), completion) }
934
+ mainHandler.post { androidx.core.app.ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.POST_NOTIFICATIONS), CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST) }
935
+ } else complete(requestToken, schedule(notification), completion)
936
+ }
895
937
  "cancel" -> {
896
938
  val id = validKey(args.opt(0))
897
939
  complete(requestToken, if (id == null) failure("INVALID_ARGUMENT", "Notifications.cancel needs an id")
@@ -909,6 +951,13 @@ internal class CraftNativeCapabilities(
909
951
  }
910
952
  }
911
953
 
954
+ fun onNotificationPermissions(requestCode: Int) {
955
+ if (requestCode != CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST) return
956
+ val complete = pendingNotificationPermission ?: return
957
+ pendingNotificationPermission = null
958
+ complete(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED)
959
+ }
960
+
912
961
  private fun schedule(value: JSONObject?): CraftNativeCapabilityResult {
913
962
  val title = value?.optString("title").orEmpty()
914
963
  if (title.isEmpty()) return failure("INVALID_ARGUMENT", "Notifications.schedule needs a title")
@@ -1,5 +1,7 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
+ import com.craft.runtime.CraftNative
4
+
3
5
  import android.Manifest
4
6
  import android.app.Activity
5
7
  import android.content.ActivityNotFoundException
@@ -29,6 +31,15 @@ import java.util.Locale
29
31
  import java.util.UUID
30
32
  {{NATIVE_PUSH_IMPORTS}}
31
33
 
34
+ private fun craftLocationFix(location: android.location.Location): JSONObject = JSONObject()
35
+ .put("latitude", location.latitude)
36
+ .put("longitude", location.longitude)
37
+ .put("altitude", location.altitude)
38
+ .put("accuracy", location.accuracy.toDouble())
39
+ .put("speed", location.speed.toDouble())
40
+ .put("heading", location.bearing.toDouble())
41
+ .put("timestamp", location.time.toDouble())
42
+
32
43
  /**
33
44
  * The capabilities a native screen asks for beyond Craft's core set
34
45
  * (CraftNativeCapabilities): the system's dialogs, the in-app browser, the
@@ -59,6 +70,16 @@ internal class CraftNativeServices(
59
70
  private val speechQueue = mutableListOf<() -> Unit>()
60
71
  private val speeches = mutableMapOf<String, (CraftNativeCapabilityResult) -> Unit>()
61
72
 
73
+ init {
74
+ if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
75
+ // Native screens must also hear a recording started by the page.
76
+ // Keep this process sink independent from the page's listener.
77
+ com.craft.runtime.CraftLocationRecordingStore.nativeListener = { location, id ->
78
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id)))
79
+ }
80
+ }
81
+ }
82
+
62
83
  /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
63
84
  var onBackgroundComplete: ((Boolean, String?) -> Unit)? = null
64
85
 
@@ -84,7 +105,11 @@ internal class CraftNativeServices(
84
105
  add("healthAvailability")
85
106
  }
86
107
  if (config.optBoolean("enableKeepAwake")) add("keepAwake")
87
- if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) add("geolocation")
108
+ if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
109
+ add("geolocation")
110
+ if (CraftNative.supportsRecordingOwnership()) add("locationRecordingOwnership")
111
+ if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
112
+ }
88
113
  if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
89
114
  // craft.crashReporter: the app's crash and error reporter (bughq).
90
115
  if (CraftCrashReporter.enabled) add("crashReporter")
@@ -130,6 +155,8 @@ internal class CraftNativeServices(
130
155
  "CrashReporter" -> crashReporter(method, args, done)
131
156
  else -> done(failure("UNKNOWN_ACTION", "Unsupported native API"))
132
157
  }
158
+ } catch (error: com.craft.runtime.CraftRecordingException) {
159
+ done(failure(error.code, error.message ?: "Recording failed"))
133
160
  } catch (error: Exception) {
134
161
  Log.e("CraftNativeAndroid", "$module.$method failed", error)
135
162
  done(failure("CRAFT_ERROR", error.message ?: "$module.$method failed"))
@@ -359,14 +386,6 @@ internal class CraftNativeServices(
359
386
  private var watchCallback: com.google.android.gms.location.LocationCallback? = null
360
387
  private var pendingLocationPermission: ((Boolean) -> Unit)? = null
361
388
 
362
- private fun fix(location: android.location.Location): JSONObject = JSONObject()
363
- .put("latitude", location.latitude)
364
- .put("longitude", location.longitude)
365
- .put("altitude", location.altitude)
366
- .put("accuracy", location.accuracy.toDouble())
367
- .put("speed", location.speed.toDouble())
368
- .put("heading", location.bearing.toDouble())
369
- .put("timestamp", location.time.toDouble())
370
389
 
371
390
  private fun hasLocation(): Boolean =
372
391
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
@@ -374,10 +393,15 @@ internal class CraftNativeServices(
374
393
 
375
394
  /** Location, asked for once a person starts something that needs it. */
376
395
  private fun withLocation(done: (CraftNativeCapabilityResult) -> Unit, then: () -> Unit) {
377
- if (hasLocation()) return then()
396
+ val authorized = {
397
+ try { then() }
398
+ catch (error: com.craft.runtime.CraftRecordingException) { done(failure(error.code, error.message ?: "Recording failed")) }
399
+ catch (error: Exception) { done(failure("CRAFT_ERROR", error.message ?: "Location failed")) }
400
+ }
401
+ if (hasLocation()) return authorized()
378
402
  val host = activity() ?: return done(failure("PERMISSION_DENIED", "Location is not allowed"))
379
403
  pendingLocationPermission?.invoke(false)
380
- pendingLocationPermission = { granted -> if (granted) then() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
404
+ pendingLocationPermission = { granted -> if (granted) authorized() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
381
405
  ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), LOCATION_PERMISSION_REQUEST)
382
406
  }
383
407
 
@@ -396,7 +420,7 @@ internal class CraftNativeServices(
396
420
  val priority = if (options.optBoolean("enableHighAccuracy", true)) com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY
397
421
  else com.google.android.gms.location.Priority.PRIORITY_BALANCED_POWER_ACCURACY
398
422
  locationClient.getCurrentLocation(priority, null)
399
- .addOnSuccessListener { location -> done(if (location != null) success(fix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
423
+ .addOnSuccessListener { location -> done(if (location != null) success(craftLocationFix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
400
424
  .addOnFailureListener { error -> done(failure("POSITION_UNAVAILABLE", error.message ?: "No location fix")) }
401
425
  }
402
426
  "watchPosition" -> withLocation(done) {
@@ -404,7 +428,7 @@ internal class CraftNativeServices(
404
428
  val callback = object : com.google.android.gms.location.LocationCallback() {
405
429
  override fun onLocationResult(result: com.google.android.gms.location.LocationResult) {
406
430
  for (location in result.locations) {
407
- CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location)))
431
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location)))
408
432
  }
409
433
  }
410
434
  }
@@ -418,29 +442,15 @@ internal class CraftNativeServices(
418
442
  watchCallback = null
419
443
  done(success(true))
420
444
  }
421
- "startRecording" -> withLocation(done) {
422
- val options = args.optJSONObject(0) ?: JSONObject()
423
- val id = options.optString("id").ifBlank { UUID.randomUUID().toString() }
424
- store.start(context, id, System.currentTimeMillis())
425
- store.listener = { location -> CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location))) }
426
- val intent = Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_START)
427
- runCatching { ContextCompat.startForegroundService(context, intent) }
428
- .onFailure { store.stop(context) }
429
- done(success(store.state(context)))
430
- }
431
- "pauseRecording", "resumeRecording" -> {
432
- store.setPaused(context, method == "pauseRecording")
433
- done(success(store.state(context)))
434
- }
435
- "stopRecording" -> {
436
- val state = store.state(context, includeLocations = true)
437
- store.stop(context)
438
- store.listener = null
439
- runCatching { context.startService(Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_STOP)) }
440
- done(success(state.put("active", false).put("paused", false)))
445
+ "startRecording", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording" -> {
446
+ val invoke = {
447
+ val answer = JSONObject(store.perform(context, method, (args.optJSONObject(0) ?: JSONObject()).toString()))
448
+ if (answer.optBoolean("ok")) {
449
+ done(success(answer.opt("data")))
450
+ } else done(failure(answer.optString("code"), answer.optString("message")))
451
+ }
452
+ if (method == "startRecording") withLocation(done, invoke) else invoke()
441
453
  }
442
- "getRecordingState" -> done(success(store.state(context)))
443
- "readRecording" -> done(success(store.locations(context)))
444
454
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Location method"))
445
455
  }
446
456
  }