craft-native 0.0.89 → 0.0.91

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 (29) hide show
  1. package/dist/android/src/index.js +285 -44
  2. package/dist/android/src/promise-runtime.d.ts +3 -0
  3. package/dist/android/templates/CraftBridge.kt.template +1951 -1193
  4. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  5. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  6. package/dist/android/templates/CraftNative.kt.template +2231 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  8. package/dist/android/templates/MainActivity.kt.template +25 -2
  9. package/dist/android/templates/proguard-rules.pro.template +4 -1
  10. package/dist/android/templates/test-bridges.html +10 -33
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/ios-advanced.d.ts +8 -5
  13. package/dist/api/live-activity-handle.d.ts +6 -0
  14. package/dist/api/mobile.d.ts +13 -5
  15. package/dist/api/window.d.ts +2 -0
  16. package/dist/cli.js +404 -128
  17. package/dist/index.cjs +65 -17
  18. package/dist/index.js +65 -17
  19. package/dist/ios/src/index.js +22 -4
  20. package/dist/ios/templates/CraftApp.swift +473 -60
  21. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  22. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  23. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  24. package/dist/ios/templates/project.yml.template +10 -4
  25. package/dist/mobile.js +36 -13
  26. package/dist/scaffold-version.d.ts +5 -0
  27. package/package.json +1 -1
  28. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  29. package/dist/android/templates/CraftWidgetProvider.kt.template +0 -246
@@ -18,12 +18,17 @@ import android.net.NetworkCapabilities
18
18
  import android.net.NetworkRequest
19
19
  import android.net.Uri
20
20
  import android.os.Build
21
+ import com.craft.runtime.CraftNative
22
+ import com.craft.runtime.CraftLocationRecordingStore
23
+ import com.craft.runtime.LocationRecordingService
21
24
  import android.os.Bundle
25
+ import android.os.Handler
22
26
  import android.os.Looper
23
27
  import android.os.VibrationEffect
24
28
  import android.os.Vibrator
25
29
  import android.os.VibratorManager
26
30
  import android.provider.MediaStore
31
+ import android.provider.OpenableColumns
27
32
  import android.provider.Settings
28
33
  import android.speech.RecognitionListener
29
34
  import android.speech.RecognizerIntent
@@ -34,7 +39,6 @@ import android.webkit.WebView
34
39
  import androidx.biometric.BiometricManager
35
40
  import androidx.biometric.BiometricPrompt
36
41
  import androidx.core.app.ActivityCompat
37
- import androidx.core.app.NotificationManagerCompat
38
42
  import androidx.core.content.ContextCompat
39
43
  import androidx.fragment.app.FragmentActivity
40
44
  import androidx.lifecycle.Lifecycle
@@ -48,6 +52,8 @@ import com.google.android.gms.location.LocationRequest
48
52
  import com.google.android.gms.location.LocationResult
49
53
  import com.google.android.gms.location.LocationServices
50
54
  import com.google.android.gms.location.Priority
55
+ import com.google.android.gms.common.ConnectionResult
56
+ import com.google.android.gms.common.GoogleApiAvailability
51
57
  import com.google.android.play.core.review.ReviewManagerFactory
52
58
  {{FIREBASE_IMPORT}}
53
59
  import android.content.ContentResolver
@@ -98,19 +104,130 @@ import java.io.File
98
104
  import java.util.concurrent.TimeUnit
99
105
  import java.util.UUID
100
106
 
107
+ internal object CraftPermissionPolicy {
108
+ data class Request(
109
+ val groupIndex: Int,
110
+ val permissions: Array<String>
111
+ )
112
+
113
+ private fun permissionGroups(name: String, sdkInt: Int): List<List<String>>? = when (name) {
114
+ "camera" -> listOf(listOf(Manifest.permission.CAMERA))
115
+ "microphone" -> listOf(listOf(Manifest.permission.RECORD_AUDIO))
116
+ "photos" -> listOf(listOf(
117
+ if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
118
+ Manifest.permission.READ_MEDIA_IMAGES
119
+ } else {
120
+ Manifest.permission.READ_EXTERNAL_STORAGE
121
+ }
122
+ ))
123
+ // Android presents precise and approximate location as one choice. A
124
+ // coarse grant is usable foreground location access even when the
125
+ // corresponding fine result is denied.
126
+ "location" -> listOf(listOf(
127
+ Manifest.permission.ACCESS_FINE_LOCATION,
128
+ Manifest.permission.ACCESS_COARSE_LOCATION
129
+ ))
130
+ "locationAlways" -> if (sdkInt >= Build.VERSION_CODES.Q) {
131
+ listOf(
132
+ listOf(
133
+ Manifest.permission.ACCESS_FINE_LOCATION,
134
+ Manifest.permission.ACCESS_COARSE_LOCATION
135
+ ),
136
+ listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
137
+ )
138
+ } else {
139
+ listOf(listOf(
140
+ Manifest.permission.ACCESS_FINE_LOCATION,
141
+ Manifest.permission.ACCESS_COARSE_LOCATION
142
+ ))
143
+ }
144
+ "notifications" -> if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
145
+ listOf(listOf(Manifest.permission.POST_NOTIFICATIONS))
146
+ } else {
147
+ emptyList()
148
+ }
149
+ "contacts" -> listOf(listOf(Manifest.permission.READ_CONTACTS))
150
+ "calendar" -> listOf(listOf(Manifest.permission.READ_CALENDAR))
151
+ "bluetooth" -> if (sdkInt >= Build.VERSION_CODES.S) {
152
+ listOf(listOf(Manifest.permission.BLUETOOTH_SCAN))
153
+ } else {
154
+ emptyList()
155
+ }
156
+ "motion" -> if (sdkInt >= Build.VERSION_CODES.Q) {
157
+ listOf(listOf(Manifest.permission.ACTIVITY_RECOGNITION))
158
+ } else {
159
+ emptyList()
160
+ }
161
+ else -> null
162
+ }
163
+
164
+ fun requiredPermissions(name: String, sdkInt: Int): Array<String>? =
165
+ permissionGroups(name, sdkInt)?.flatten()?.distinct()?.toTypedArray()
166
+
167
+ fun status(name: String, sdkInt: Int, isGranted: (String) -> Boolean): String {
168
+ val groups = permissionGroups(name, sdkInt) ?: return "undetermined"
169
+ return if (groups.all { group -> group.any(isGranted) }) "granted" else "denied"
170
+ }
171
+
172
+ fun nextRequest(
173
+ name: String,
174
+ sdkInt: Int,
175
+ startingAt: Int,
176
+ isGranted: (String) -> Boolean
177
+ ): Request? {
178
+ val groups = permissionGroups(name, sdkInt) ?: return null
179
+ val groupIndex = (startingAt until groups.size).firstOrNull { index ->
180
+ groups[index].none(isGranted)
181
+ } ?: return null
182
+ return Request(groupIndex, groups[groupIndex].toTypedArray())
183
+ }
184
+
185
+ fun groupIsGranted(
186
+ name: String,
187
+ sdkInt: Int,
188
+ groupIndex: Int,
189
+ isGranted: (String) -> Boolean
190
+ ): Boolean = permissionGroups(name, sdkInt)
191
+ ?.getOrNull(groupIndex)
192
+ ?.any(isGranted) == true
193
+
194
+ fun foregroundLocationIsGranted(isGranted: (String) -> Boolean): Boolean =
195
+ isGranted(Manifest.permission.ACCESS_FINE_LOCATION) ||
196
+ isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
197
+ }
198
+
199
+ private data class PendingPermissionRequest(
200
+ val callbackId: Int,
201
+ val permission: String,
202
+ val groupIndex: Int
203
+ )
204
+
101
205
  class CraftBridge(
102
206
  private val activity: Activity,
103
207
  private val webView: WebView,
104
208
  private val healthConnect: CraftHealthConnect
105
209
  ) {
210
+ @Volatile
211
+ private var closed = false
212
+ private var nativeDeepLinks = CraftNative.resetDeepLinks()
106
213
  private var speechRecognizer: SpeechRecognizer? = null
214
+ private var biometricPrompt: BiometricPrompt? = null
107
215
  private var isListening = false
108
216
  private val mainExecutor: Executor = ContextCompat.getMainExecutor(activity)
217
+ @Volatile
218
+ private var bridgeReady = false
219
+ private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
109
220
 
110
221
  // Location tracking
111
222
  private var fusedLocationClient: FusedLocationProviderClient? = null
112
- private var locationCallback: LocationCallback? = null
223
+ private val locationCallbacks = mutableMapOf<Int, LocationCallback>()
224
+ private val oneShotLocationCallbacks = mutableSetOf<LocationCallback>()
225
+ private val oneShotLocationHandler = Handler(Looper.getMainLooper())
226
+ private val oneShotLocationTimeouts = mutableMapOf<LocationCallback, Runnable>()
227
+ private val currentPositionSequence = java.util.concurrent.atomic.AtomicLong(0)
113
228
  private var watchId = 0
229
+ private val pendingPermissionRequests = mutableMapOf<Int, PendingPermissionRequest>()
230
+ private var nextPermissionRequestCode = PERMISSION_REQUEST_START
114
231
 
115
232
  // Network monitoring
116
233
  private var networkCallback: ConnectivityManager.NetworkCallback? = null
@@ -124,8 +241,9 @@ class CraftBridge(
124
241
  // Keep awake
125
242
  private var isKeepingAwake = false
126
243
 
127
- // Billing client for in-app purchases
128
- private var billingClient: BillingClient? = null
244
+ // Billing clients for in-app purchases
245
+ private var productBillingClient: BillingClient? = null
246
+ private var restoreBillingClient: BillingClient? = null
129
247
 
130
248
  // Notification channel ID
131
249
  private val notificationChannelId = "craft_notifications"
@@ -165,8 +283,33 @@ class CraftBridge(
165
283
  )
166
284
  }
167
285
 
286
+ /**
287
+ * Quote a value for interpolation into JavaScript.
288
+ *
289
+ * The result carries its own quotes, so write `f($payload)` — leaving the
290
+ * surrounding `'...'` in place would quote it twice.
291
+ *
292
+ * Without this, a value containing an apostrophe ends the JS string early, the
293
+ * script fails to parse, and `evaluateJavascript` runs nothing at all — so a
294
+ * hand-built promise with no timeout never settles.
295
+ *
296
+ * `Any?` rather than `String?`, because these call sites replaced string
297
+ * interpolation and interpolation accepts anything: `contactId` is the `Long`
298
+ * from `ContentUris.parseId`, `downloadId` the `Long` from
299
+ * `DownloadManager.enqueue`, and `errString` the `CharSequence` a
300
+ * `BiometricPrompt` error arrives as. `Any?.toString()` is exactly what `$x`
301
+ * did, null included — it renders as the string "null", the way it always has.
302
+ */
303
+ private fun jsQuote(value: Any?): String = JSONObject.quote(value.toString())
304
+
168
305
  fun injectBridge() {
306
+ // Before the script, so a native callback arriving while the page is
307
+ // still loading has somewhere to go rather than being counted as
308
+ // dropped.
309
+ installNativeDeliverer()
310
+
169
311
  val script = """
312
+ {{PROMISE_RUNTIME}}
170
313
  window.craft = {
171
314
  platform: 'android',
172
315
  capabilities: {
@@ -181,7 +324,7 @@ class CraftBridge(
181
324
  backgroundLocation: {{ENABLE_BACKGROUND_LOCATION}},
182
325
  clipboard: false,
183
326
  deviceInfo: true,
184
- appBadge: {{ENABLE_PUSH}},
327
+ appBadge: false,
185
328
  networkStatus: true,
186
329
  appReview: true,
187
330
  flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
@@ -229,34 +372,26 @@ class CraftBridge(
229
372
 
230
373
  // Camera
231
374
  openCamera: function() {
232
- return new Promise(function(resolve, reject) {
233
- window._craftCameraResolve = resolve;
234
- window._craftCameraReject = reject;
375
+ return window.__craftPromise('camera', '_craftCameraResolve', '_craftCameraReject', function() {
235
376
  CraftAndroid.openCamera();
236
377
  });
237
378
  },
238
379
  pickImage: function() {
239
- return new Promise(function(resolve, reject) {
240
- window._craftGalleryResolve = resolve;
241
- window._craftGalleryReject = reject;
380
+ return window.__craftPromise('gallery', '_craftGalleryResolve', '_craftGalleryReject', function() {
242
381
  CraftAndroid.pickImage();
243
382
  });
244
383
  },
245
384
 
246
385
  // Biometric auth
247
386
  authenticate: function(reason) {
248
- return new Promise(function(resolve, reject) {
249
- window._craftBiometricResolve = resolve;
250
- window._craftBiometricReject = reject;
387
+ return window.__craftPromise('biometric', '_craftBiometricResolve', '_craftBiometricReject', function() {
251
388
  CraftAndroid.authenticate(reason || 'Authenticate to continue');
252
389
  });
253
390
  },
254
391
 
255
392
  // Push notifications
256
393
  registerPush: function() {
257
- return new Promise(function(resolve, reject) {
258
- window._craftPushResolve = resolve;
259
- window._craftPushReject = reject;
394
+ return window.__craftPromise('push registration', '_craftPushResolve', '_craftPushReject', function() {
260
395
  CraftAndroid.registerPush();
261
396
  });
262
397
  },
@@ -276,11 +411,14 @@ class CraftBridge(
276
411
 
277
412
  // Geolocation
278
413
  getCurrentPosition: function(options) {
279
- return new Promise(function(resolve, reject) {
280
- window._craftLocationResolve = resolve;
281
- window._craftLocationReject = reject;
282
- CraftAndroid.getCurrentPosition(JSON.stringify(options || {}));
283
- });
414
+ return window.__craftPromise(
415
+ 'location',
416
+ '_craftLocationResolve',
417
+ '_craftLocationReject',
418
+ function() { CraftAndroid.getCurrentPosition(JSON.stringify(options || {})); },
419
+ 15000,
420
+ {code: 2, message: 'Location request timed out; Google Play Services or a location provider may be unavailable'}
421
+ );
284
422
  },
285
423
  watchPosition: function(callback, options) {
286
424
  var watchId = CraftAndroid.watchPosition(JSON.stringify(options || {}));
@@ -312,11 +450,11 @@ class CraftBridge(
312
450
  },
313
451
 
314
452
  // App Badge
315
- setBadge: function(count) {
316
- CraftAndroid.setBadge(count);
453
+ setBadge: function() {
454
+ throw new Error('App badges are unavailable on Android');
317
455
  },
318
456
  clearBadge: function() {
319
- CraftAndroid.clearBadge();
457
+ throw new Error('App badges are unavailable on Android');
320
458
  },
321
459
 
322
460
  // Network Status
@@ -334,9 +472,7 @@ class CraftBridge(
334
472
 
335
473
  // App Review
336
474
  requestReview: function() {
337
- return new Promise(function(resolve, reject) {
338
- window._craftReviewResolve = resolve;
339
- window._craftReviewReject = reject;
475
+ return window.__craftPromise('app review', '_craftReviewResolve', '_craftReviewReject', function() {
340
476
  CraftAndroid.requestReview();
341
477
  });
342
478
  },
@@ -375,16 +511,12 @@ class CraftBridge(
375
511
  // Contacts
376
512
  contacts: {
377
513
  getAll: function() {
378
- return new Promise(function(resolve, reject) {
379
- window._craftContactsResolve = resolve;
380
- window._craftContactsReject = reject;
514
+ return window.__craftPromise('contacts read', '_craftContactsResolve', '_craftContactsReject', function() {
381
515
  CraftAndroid.getContacts();
382
516
  });
383
517
  },
384
518
  add: function(contact) {
385
- return new Promise(function(resolve, reject) {
386
- window._craftAddContactResolve = resolve;
387
- window._craftAddContactReject = reject;
519
+ return window.__craftPromise('contact write', '_craftAddContactResolve', '_craftAddContactReject', function() {
388
520
  CraftAndroid.addContact(JSON.stringify(contact));
389
521
  });
390
522
  }
@@ -393,23 +525,17 @@ class CraftBridge(
393
525
  // Calendar
394
526
  calendar: {
395
527
  getEvents: function(startDate, endDate) {
396
- return new Promise(function(resolve, reject) {
397
- window._craftCalendarResolve = resolve;
398
- window._craftCalendarReject = reject;
528
+ return window.__craftPromise('calendar read', '_craftCalendarResolve', '_craftCalendarReject', function() {
399
529
  CraftAndroid.getCalendarEvents(startDate || 0, endDate || 0);
400
530
  });
401
531
  },
402
532
  createEvent: function(event) {
403
- return new Promise(function(resolve, reject) {
404
- window._craftCreateEventResolve = resolve;
405
- window._craftCreateEventReject = reject;
533
+ return window.__craftPromise('calendar create', '_craftCreateEventResolve', '_craftCreateEventReject', function() {
406
534
  CraftAndroid.createCalendarEvent(JSON.stringify(event));
407
535
  });
408
536
  },
409
537
  deleteEvent: function(eventId) {
410
- return new Promise(function(resolve, reject) {
411
- window._craftDeleteEventResolve = resolve;
412
- window._craftDeleteEventReject = reject;
538
+ return window.__craftPromise('calendar delete', '_craftDeleteEventResolve', '_craftDeleteEventReject', function() {
413
539
  CraftAndroid.deleteCalendarEvent(eventId);
414
540
  });
415
541
  }
@@ -418,9 +544,7 @@ class CraftBridge(
418
544
  // Local Notifications
419
545
  notifications: {
420
546
  schedule: function(notification) {
421
- return new Promise(function(resolve, reject) {
422
- window._craftNotifResolve = resolve;
423
- window._craftNotifReject = reject;
547
+ return window.__craftPromise('notification schedule', '_craftNotifResolve', '_craftNotifReject', function() {
424
548
  CraftAndroid.scheduleNotification(JSON.stringify(notification));
425
549
  });
426
550
  },
@@ -435,23 +559,17 @@ class CraftBridge(
435
559
  // In-App Purchase
436
560
  iap: {
437
561
  getProducts: function(productIds) {
438
- return new Promise(function(resolve, reject) {
439
- window._craftProductsResolve = resolve;
440
- window._craftProductsReject = reject;
562
+ return window.__craftPromise('products', '_craftProductsResolve', '_craftProductsReject', function() {
441
563
  CraftAndroid.getProducts(JSON.stringify(productIds));
442
564
  });
443
565
  },
444
566
  purchase: function(productId) {
445
- return new Promise(function(resolve, reject) {
446
- window._craftPurchaseResolve = resolve;
447
- window._craftPurchaseReject = reject;
567
+ return window.__craftPromise('purchase', '_craftPurchaseResolve', '_craftPurchaseReject', function() {
448
568
  CraftAndroid.purchase(productId);
449
569
  });
450
570
  },
451
571
  restore: function() {
452
- return new Promise(function(resolve, reject) {
453
- window._craftRestoreResolve = resolve;
454
- window._craftRestoreReject = reject;
572
+ return window.__craftPromise('purchase restore', '_craftRestoreResolve', '_craftRestoreReject', function() {
455
573
  CraftAndroid.restorePurchases();
456
574
  });
457
575
  }
@@ -477,68 +595,52 @@ class CraftBridge(
477
595
 
478
596
  // QR/Barcode Scanner
479
597
  scanQRCode: function() {
480
- return new Promise(function(resolve, reject) {
481
- window._craftQRResolve = resolve;
482
- window._craftQRReject = reject;
598
+ return window.__craftPromise('QR scan', '_craftQRResolve', '_craftQRReject', function() {
483
599
  CraftAndroid.scanQRCode();
484
600
  });
485
601
  },
486
602
 
487
603
  // File Picker
488
604
  pickFile: function(types) {
489
- return new Promise(function(resolve, reject) {
490
- window._craftFileResolve = resolve;
491
- window._craftFileReject = reject;
605
+ return window.__craftPromise('file picker', '_craftFileResolve', '_craftFileReject', function() {
492
606
  CraftAndroid.pickFile(JSON.stringify(types || []));
493
607
  });
494
608
  },
495
609
 
496
610
  // File Download
497
611
  downloadFile: function(url, filename) {
498
- return new Promise(function(resolve, reject) {
499
- window._craftDownloadResolve = resolve;
500
- window._craftDownloadReject = reject;
612
+ return window.__craftPromise('file download', '_craftDownloadResolve', '_craftDownloadReject', function() {
501
613
  CraftAndroid.downloadFile(url, filename);
502
614
  });
503
615
  },
504
616
  saveFile: function(data, filename) {
505
- return new Promise(function(resolve, reject) {
506
- window._craftSaveResolve = resolve;
507
- window._craftSaveReject = reject;
617
+ return window.__craftPromise('file save', '_craftSaveResolve', '_craftSaveReject', function() {
508
618
  CraftAndroid.saveFile(data, filename);
509
619
  });
510
620
  },
511
621
 
512
622
  // Google Sign In
513
623
  signInWithGoogle: function() {
514
- return new Promise(function(resolve, reject) {
515
- window._craftGoogleResolve = resolve;
516
- window._craftGoogleReject = reject;
624
+ return window.__craftPromise('Google sign in', '_craftGoogleResolve', '_craftGoogleReject', function() {
517
625
  CraftAndroid.signInWithGoogle();
518
626
  });
519
627
  },
520
628
 
521
629
  // Audio Recording
522
630
  startAudioRecording: function() {
523
- return new Promise(function(resolve, reject) {
524
- window._craftAudioResolve = resolve;
525
- window._craftAudioReject = reject;
631
+ return window.__craftPromise('audio start', '_craftAudioResolve', '_craftAudioReject', function() {
526
632
  CraftAndroid.startAudioRecording();
527
633
  });
528
634
  },
529
635
  stopAudioRecording: function() {
530
- return new Promise(function(resolve, reject) {
531
- window._craftAudioStopResolve = resolve;
532
- window._craftAudioStopReject = reject;
636
+ return window.__craftPromise('audio stop', '_craftAudioStopResolve', '_craftAudioStopReject', function() {
533
637
  CraftAndroid.stopAudioRecording();
534
638
  });
535
639
  },
536
640
 
537
641
  // Video Recording
538
642
  startVideoRecording: function() {
539
- return new Promise(function(resolve, reject) {
540
- window._craftVideoResolve = resolve;
541
- window._craftVideoReject = reject;
643
+ return window.__craftPromise('video recording', '_craftVideoResolve', '_craftVideoReject', function() {
542
644
  CraftAndroid.startVideoRecording();
543
645
  });
544
646
  },
@@ -557,16 +659,12 @@ class CraftBridge(
557
659
  // Local Database
558
660
  db: {
559
661
  execute: function(sql, params) {
560
- return new Promise(function(resolve, reject) {
561
- window._craftDbExecResolve = resolve;
562
- window._craftDbExecReject = reject;
662
+ return window.__craftPromise('database execute', '_craftDbExecResolve', '_craftDbExecReject', function() {
563
663
  CraftAndroid.dbExecute(sql, JSON.stringify(params || []));
564
664
  });
565
665
  },
566
666
  query: function(sql, params) {
567
- return new Promise(function(resolve, reject) {
568
- window._craftDbQueryResolve = resolve;
569
- window._craftDbQueryReject = reject;
667
+ return window.__craftPromise('database query', '_craftDbQueryResolve', '_craftDbQueryReject', function() {
570
668
  CraftAndroid.dbQuery(sql, JSON.stringify(params || []));
571
669
  });
572
670
  }
@@ -575,9 +673,7 @@ class CraftBridge(
575
673
  // Bluetooth
576
674
  bluetooth: {
577
675
  startScan: function() {
578
- return new Promise(function(resolve, reject) {
579
- window._craftBleResolve = resolve;
580
- window._craftBleReject = reject;
676
+ return window.__craftPromise('Bluetooth scan', '_craftBleResolve', '_craftBleReject', function() {
581
677
  CraftAndroid.startBluetoothScan();
582
678
  });
583
679
  },
@@ -591,9 +687,7 @@ class CraftBridge(
591
687
 
592
688
  // NFC
593
689
  scanNFC: function() {
594
- return new Promise(function(resolve, reject) {
595
- window._craftNfcResolve = resolve;
596
- window._craftNfcReject = reject;
690
+ return window.__craftPromise('NFC scan', '_craftNfcResolve', '_craftNfcReject', function() {
597
691
  CraftAndroid.scanNFC();
598
692
  });
599
693
  },
@@ -601,34 +695,91 @@ class CraftBridge(
601
695
  // Fitness / Health
602
696
  health: {
603
697
  requestAuthorization: function(types) {
604
- return new Promise(function(resolve, reject) {
605
- window._craftFitnessAuthResolve = resolve;
606
- window._craftFitnessAuthReject = reject;
698
+ return window.__craftPromise('health authorization', '_craftFitnessAuthResolve', '_craftFitnessAuthReject', function() {
607
699
  CraftAndroid.requestFitnessAuthorization(JSON.stringify(types || []));
608
700
  });
609
701
  },
610
702
  getData: function(type, options) {
611
703
  options = options || {};
612
- return new Promise(function(resolve, reject) {
613
- window._craftFitnessDataResolve = resolve;
614
- window._craftFitnessDataReject = reject;
704
+ return window.__craftPromise('health read', '_craftFitnessDataResolve', '_craftFitnessDataReject', function() {
615
705
  CraftAndroid.getFitnessData(type, options.startDate || 0, options.endDate || 0);
616
706
  });
617
707
  },
618
708
  saveWorkout: function(workout) {
619
- return new Promise(function(resolve, reject) {
620
- window._craftFitnessSaveResolve = resolve;
621
- window._craftFitnessSaveReject = reject;
709
+ return window.__craftPromise('health write', '_craftFitnessSaveResolve', '_craftFitnessSaveReject', function() {
622
710
  CraftAndroid.saveHealthWorkout(JSON.stringify(workout || {}));
623
711
  });
624
712
  }
625
713
  },
626
714
 
715
+ // Flat SDK compatibility methods. Delegate to the namespaced
716
+ // bridge where Android implements the capability, and reject
717
+ // explicitly for APIs that are intentionally iOS-only.
718
+ getContacts: function() {
719
+ return window.craft.contacts.getAll();
720
+ },
721
+ addContact: function(contact) {
722
+ return window.craft.contacts.add(contact);
723
+ },
724
+ getCalendarEvents: function(startDate, endDate) {
725
+ return window.craft.calendar.getEvents(startDate, endDate);
726
+ },
727
+ createCalendarEvent: function(event) {
728
+ return window.craft.calendar.createEvent(event);
729
+ },
730
+ deleteCalendarEvent: function(eventId) {
731
+ return window.craft.calendar.deleteEvent(eventId);
732
+ },
733
+ scheduleNotification: function(notification) {
734
+ return window.craft.notifications.schedule(notification);
735
+ },
736
+ cancelNotification: function(id) {
737
+ return Promise.resolve().then(function() { return window.craft.notifications.cancel(id); });
738
+ },
739
+ cancelAllNotifications: function() {
740
+ return Promise.resolve().then(function() { return window.craft.notifications.cancelAll(); });
741
+ },
742
+ getPendingNotifications: function() {
743
+ return Promise.reject(new Error('Pending notification queries are unavailable on Android'));
744
+ },
745
+ getProducts: function(productIds) {
746
+ return window.craft.iap.getProducts(productIds);
747
+ },
748
+ purchase: function(productId) {
749
+ return window.craft.iap.purchase(productId);
750
+ },
751
+ restorePurchases: function() {
752
+ return window.craft.iap.restore();
753
+ },
754
+ signInWithApple: function() {
755
+ return Promise.reject(new Error('Sign in with Apple is unavailable on Android'));
756
+ },
757
+ startBluetoothScan: function() {
758
+ return window.craft.bluetooth.startScan();
759
+ },
760
+ stopBluetoothScan: function() {
761
+ return Promise.resolve().then(function() { return window.craft.bluetooth.stopScan(); });
762
+ },
763
+ requestHealthAuthorization: function() {
764
+ return Promise.reject(new Error('HealthKit is unavailable on Android'));
765
+ },
766
+ getHealthData: function() {
767
+ return Promise.reject(new Error('HealthKit is unavailable on Android'));
768
+ },
769
+ requestFitnessAuthorization: function(types) {
770
+ return window.craft.health.requestAuthorization(types || [
771
+ 'steps', 'heartRate', 'activeEnergy', 'distance', 'workouts'
772
+ ]);
773
+ },
774
+ getFitnessData: function(type, startDate, endDate) {
775
+ var start = startDate instanceof Date ? startDate.getTime() : startDate;
776
+ var end = endDate instanceof Date ? endDate.getTime() : endDate;
777
+ return window.craft.health.getData(type, {startDate: start, endDate: end});
778
+ },
779
+
627
780
  // Screen Capture
628
781
  takeScreenshot: function() {
629
- return new Promise(function(resolve, reject) {
630
- window._craftScreenshotResolve = resolve;
631
- window._craftScreenshotReject = reject;
782
+ return window.__craftPromise('screenshot', '_craftScreenshotResolve', '_craftScreenshotReject', function() {
632
783
  CraftAndroid.takeScreenshot();
633
784
  });
634
785
  },
@@ -636,17 +787,13 @@ class CraftBridge(
636
787
  // Background Tasks
637
788
  backgroundTask: {
638
789
  register: function(taskId) {
639
- return new Promise(function(resolve, reject) {
640
- window._craftBgTaskResolve = resolve;
641
- window._craftBgTaskReject = reject;
790
+ return window.__craftPromise('background task', '_craftBgTaskResolve', '_craftBgTaskReject', function() {
642
791
  CraftAndroid.registerBackgroundTask(taskId);
643
792
  });
644
793
  },
645
794
  schedule: function(taskId, options) {
646
795
  options = options || {};
647
- return new Promise(function(resolve, reject) {
648
- window._craftBgTaskResolve = resolve;
649
- window._craftBgTaskReject = reject;
796
+ return window.__craftPromise('background task', '_craftBgTaskResolve', '_craftBgTaskReject', function() {
650
797
  CraftAndroid.scheduleBackgroundTask(
651
798
  taskId,
652
799
  options.delay || 900,
@@ -656,16 +803,12 @@ class CraftBridge(
656
803
  });
657
804
  },
658
805
  cancel: function(taskId) {
659
- return new Promise(function(resolve, reject) {
660
- window._craftBgTaskResolve = resolve;
661
- window._craftBgTaskReject = reject;
806
+ return window.__craftPromise('background task', '_craftBgTaskResolve', '_craftBgTaskReject', function() {
662
807
  CraftAndroid.cancelBackgroundTask(taskId);
663
808
  });
664
809
  },
665
810
  cancelAll: function() {
666
- return new Promise(function(resolve, reject) {
667
- window._craftBgTaskResolve = resolve;
668
- window._craftBgTaskReject = reject;
811
+ return window.__craftPromise('background task', '_craftBgTaskResolve', '_craftBgTaskReject', function() {
669
812
  CraftAndroid.cancelAllBackgroundTasks();
670
813
  });
671
814
  }
@@ -673,16 +816,12 @@ class CraftBridge(
673
816
 
674
817
  // PDF Viewer
675
818
  openPDF: function(source, page) {
676
- return new Promise(function(resolve, reject) {
677
- window._craftPDFResolve = resolve;
678
- window._craftPDFReject = reject;
819
+ return window.__craftPromise('PDF', '_craftPDFResolve', '_craftPDFReject', function() {
679
820
  CraftAndroid.openPDF(source, page || 0);
680
821
  });
681
822
  },
682
823
  closePDF: function() {
683
- return new Promise(function(resolve, reject) {
684
- window._craftPDFResolve = resolve;
685
- window._craftPDFReject = reject;
824
+ return window.__craftPromise('PDF', '_craftPDFResolve', '_craftPDFReject', function() {
686
825
  CraftAndroid.closePDF();
687
826
  });
688
827
  },
@@ -690,9 +829,7 @@ class CraftBridge(
690
829
  // Contacts Picker
691
830
  pickContact: function(options) {
692
831
  options = options || {};
693
- return new Promise(function(resolve, reject) {
694
- window._craftPickContactResolve = resolve;
695
- window._craftPickContactReject = reject;
832
+ return window.__craftPromise('contact picker', '_craftPickContactResolve', '_craftPickContactReject', function() {
696
833
  CraftAndroid.pickContact(options.multiple || false);
697
834
  });
698
835
  },
@@ -700,16 +837,12 @@ class CraftBridge(
700
837
  // App Shortcuts
701
838
  shortcuts: {
702
839
  set: function(shortcuts) {
703
- return new Promise(function(resolve, reject) {
704
- window._craftShortcutsResolve = resolve;
705
- window._craftShortcutsReject = reject;
840
+ return window.__craftPromise('shortcuts', '_craftShortcutsResolve', '_craftShortcutsReject', function() {
706
841
  CraftAndroid.setShortcuts(JSON.stringify(shortcuts));
707
842
  });
708
843
  },
709
844
  clear: function() {
710
- return new Promise(function(resolve, reject) {
711
- window._craftShortcutsResolve = resolve;
712
- window._craftShortcutsReject = reject;
845
+ return window.__craftPromise('shortcuts', '_craftShortcutsResolve', '_craftShortcutsReject', function() {
713
846
  CraftAndroid.clearShortcuts();
714
847
  });
715
848
  },
@@ -721,23 +854,17 @@ class CraftBridge(
721
854
  // Shared Preferences (Android equivalent of Keychain Sharing)
722
855
  sharedKeychain: {
723
856
  set: function(key, value, group) {
724
- return new Promise(function(resolve, reject) {
725
- window._craftSharedKeychainResolve = resolve;
726
- window._craftSharedKeychainReject = reject;
857
+ return window.__craftPromise('shared keychain', '_craftSharedKeychainResolve', '_craftSharedKeychainReject', function() {
727
858
  CraftAndroid.setSharedItem(key, value, group || '');
728
859
  });
729
860
  },
730
861
  get: function(key, group) {
731
- return new Promise(function(resolve, reject) {
732
- window._craftSharedKeychainResolve = resolve;
733
- window._craftSharedKeychainReject = reject;
862
+ return window.__craftPromise('shared keychain', '_craftSharedKeychainResolve', '_craftSharedKeychainReject', function() {
734
863
  CraftAndroid.getSharedItem(key, group || '');
735
864
  });
736
865
  },
737
866
  remove: function(key, group) {
738
- return new Promise(function(resolve, reject) {
739
- window._craftSharedKeychainResolve = resolve;
740
- window._craftSharedKeychainReject = reject;
867
+ return window.__craftPromise('shared keychain', '_craftSharedKeychainResolve', '_craftSharedKeychainReject', function() {
741
868
  CraftAndroid.removeSharedItem(key, group || '');
742
869
  });
743
870
  }
@@ -746,30 +873,22 @@ class CraftBridge(
746
873
  // Local Auth Persistence
747
874
  authPersistence: {
748
875
  enable: function(duration) {
749
- return new Promise(function(resolve, reject) {
750
- window._craftAuthPersistResolve = resolve;
751
- window._craftAuthPersistReject = reject;
876
+ return window.__craftPromise('auth persistence', '_craftAuthPersistResolve', '_craftAuthPersistReject', function() {
752
877
  CraftAndroid.setAuthPersistence(true, duration || 300);
753
878
  });
754
879
  },
755
880
  disable: function() {
756
- return new Promise(function(resolve, reject) {
757
- window._craftAuthPersistResolve = resolve;
758
- window._craftAuthPersistReject = reject;
881
+ return window.__craftPromise('auth persistence', '_craftAuthPersistResolve', '_craftAuthPersistReject', function() {
759
882
  CraftAndroid.setAuthPersistence(false, 0);
760
883
  });
761
884
  },
762
885
  check: function() {
763
- return new Promise(function(resolve, reject) {
764
- window._craftAuthPersistResolve = resolve;
765
- window._craftAuthPersistReject = reject;
886
+ return window.__craftPromise('auth persistence', '_craftAuthPersistResolve', '_craftAuthPersistReject', function() {
766
887
  CraftAndroid.checkAuthPersistence();
767
888
  });
768
889
  },
769
890
  clear: function() {
770
- return new Promise(function(resolve, reject) {
771
- window._craftAuthPersistResolve = resolve;
772
- window._craftAuthPersistReject = reject;
891
+ return window.__craftPromise('auth persistence', '_craftAuthPersistResolve', '_craftAuthPersistReject', function() {
773
892
  CraftAndroid.clearAuthPersistence();
774
893
  });
775
894
  }
@@ -778,76 +897,101 @@ class CraftBridge(
778
897
  // AR (ARCore) - Note: Full ARCore requires native Activity integration
779
898
  ar: {
780
899
  start: function(options) {
781
- return new Promise(function(resolve, reject) {
782
- window._craftARResolve = resolve;
783
- window._craftARReject = reject;
900
+ return window.__craftPromise('AR', '_craftARResolve', '_craftARReject', function() {
784
901
  CraftAndroid.startAR(JSON.stringify(options || {}));
785
902
  });
786
903
  },
787
904
  stop: function() {
788
- return new Promise(function(resolve, reject) {
789
- window._craftARResolve = resolve;
790
- window._craftARReject = reject;
905
+ return window.__craftPromise('AR', '_craftARResolve', '_craftARReject', function() {
791
906
  CraftAndroid.stopAR();
792
907
  });
793
908
  },
794
909
  placeObject: function(model, position) {
795
- return new Promise(function(resolve, reject) {
796
- window._craftARResolve = resolve;
797
- window._craftARReject = reject;
910
+ return window.__craftPromise('AR', '_craftARResolve', '_craftARReject', function() {
798
911
  CraftAndroid.placeARObject(model, JSON.stringify(position || {}));
799
912
  });
800
913
  },
801
914
  removeObject: function(objectId) {
802
- return new Promise(function(resolve, reject) {
803
- window._craftARResolve = resolve;
804
- window._craftARReject = reject;
915
+ return window.__craftPromise('AR', '_craftARResolve', '_craftARReject', function() {
805
916
  CraftAndroid.removeARObject(objectId);
806
917
  });
807
918
  },
808
919
  getPlanes: function() {
809
- return new Promise(function(resolve, reject) {
810
- window._craftARResolve = resolve;
811
- window._craftARReject = reject;
920
+ return window.__craftPromise('AR', '_craftARResolve', '_craftARReject', function() {
812
921
  CraftAndroid.getARPlanes();
813
922
  });
814
923
  },
815
924
  onPlaneDetected: function(callback) {
816
- window.addEventListener('craftARPlane', function(e) { callback(e.detail); });
925
+ throw new Error('craft.ar.onPlaneDetected is unavailable because ARCore requires native Activity integration');
817
926
  }
818
927
  },
819
928
 
820
929
  // ML (ML Kit)
821
930
  ml: {
822
931
  classifyImage: function(imageBase64) {
823
- return new Promise(function(resolve, reject) {
824
- window._craftMLResolve = resolve;
825
- window._craftMLReject = reject;
932
+ return window.__craftPromise('ML', '_craftMLResolve', '_craftMLReject', function() {
826
933
  CraftAndroid.classifyImage(imageBase64);
827
934
  });
828
935
  },
829
936
  detectObjects: function(imageBase64) {
830
- return new Promise(function(resolve, reject) {
831
- window._craftMLResolve = resolve;
832
- window._craftMLReject = reject;
937
+ return window.__craftPromise('ML', '_craftMLResolve', '_craftMLReject', function() {
833
938
  CraftAndroid.detectObjects(imageBase64);
834
939
  });
835
940
  },
836
941
  recognizeText: function(imageBase64) {
837
- return new Promise(function(resolve, reject) {
838
- window._craftMLResolve = resolve;
839
- window._craftMLReject = reject;
942
+ return window.__craftPromise('ML', '_craftMLResolve', '_craftMLReject', function() {
840
943
  CraftAndroid.recognizeText(imageBase64);
841
944
  });
842
945
  }
843
946
  },
844
947
 
948
+ // Android home-screen widgets
949
+ widgets: {
950
+ updateWidget: function(widgetId, data) {
951
+ var payload = data === undefined ? (widgetId || {}) : Object.assign({}, data || {}, {widgetId: widgetId});
952
+ return window.__craftPromise('widgets', '_craftWidgetResolve', '_craftWidgetReject', function() {
953
+ CraftAndroid.updateWidget(JSON.stringify(payload));
954
+ });
955
+ },
956
+ updateAllWidgets: function(widgetClass, data) {
957
+ var payload = Object.assign({}, data || {}, {widgetClass: widgetClass});
958
+ return window.__craftPromise('widgets', '_craftWidgetResolve', '_craftWidgetReject', function() {
959
+ CraftAndroid.updateWidget(JSON.stringify(payload));
960
+ });
961
+ },
962
+ reload: function() {
963
+ return window.__craftPromise('widgets', '_craftWidgetResolve', '_craftWidgetReject', function() {
964
+ CraftAndroid.reloadWidgets();
965
+ });
966
+ },
967
+ getActiveWidgets: function() { return Promise.resolve([]); },
968
+ requestPin: function() { return Promise.resolve(false); },
969
+ isSupported: function() { return Promise.resolve(false); }
970
+ },
971
+
972
+ // Wear OS companion connectivity
973
+ watch: {
974
+ send: function(message) {
975
+ return window.__craftPromise('watch', '_craftWatchResolve', '_craftWatchReject', function() {
976
+ CraftAndroid.sendToWatch(JSON.stringify(message));
977
+ });
978
+ },
979
+ updateContext: function(context) {
980
+ return window.__craftPromise('watch', '_craftWatchResolve', '_craftWatchReject', function() {
981
+ CraftAndroid.updateWatchContext(JSON.stringify(context));
982
+ });
983
+ },
984
+ isReachable: function() {
985
+ return Promise.resolve().then(function() {
986
+ return {reachable: CraftAndroid.isWatchReachable()};
987
+ });
988
+ }
989
+ },
990
+
845
991
  // Deep Links
846
992
  deepLinks: {
847
993
  getInitialURL: function() {
848
- return new Promise(function(resolve, reject) {
849
- window._craftDeepLinkResolve = resolve;
850
- window._craftDeepLinkReject = reject;
994
+ return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() {
851
995
  CraftAndroid.getInitialURL();
852
996
  });
853
997
  },
@@ -932,38 +1076,28 @@ class CraftBridge(
932
1076
  ota: {
933
1077
  _config: null,
934
1078
  _status: 'idle',
935
- _progressCallbacks: [],
936
- _statusCallbacks: [],
937
1079
 
938
1080
  configure: function(options) {
939
1081
  this._config = options;
940
1082
  CraftAndroid.otaConfigure(JSON.stringify(options));
941
1083
  },
942
1084
  checkForUpdate: function() {
943
- return new Promise(function(resolve, reject) {
944
- window._craftOTACheckResolve = resolve;
945
- window._craftOTACheckReject = reject;
1085
+ return window.__craftPromise('OTA check', '_craftOTACheckResolve', '_craftOTACheckReject', function() {
946
1086
  CraftAndroid.otaCheckForUpdate();
947
1087
  });
948
1088
  },
949
1089
  downloadUpdate: function(options) {
950
- return new Promise(function(resolve, reject) {
951
- window._craftOTADownloadResolve = resolve;
952
- window._craftOTADownloadReject = reject;
1090
+ return window.__craftPromise('OTA download', '_craftOTADownloadResolve', '_craftOTADownloadReject', function() {
953
1091
  CraftAndroid.otaDownloadUpdate(JSON.stringify(options || {}));
954
1092
  });
955
1093
  },
956
1094
  applyUpdate: function() {
957
- return new Promise(function(resolve, reject) {
958
- window._craftOTAApplyResolve = resolve;
959
- window._craftOTAApplyReject = reject;
1095
+ return window.__craftPromise('OTA apply', '_craftOTAApplyResolve', '_craftOTAApplyReject', function() {
960
1096
  CraftAndroid.otaApplyUpdate();
961
1097
  });
962
1098
  },
963
1099
  rollback: function() {
964
- return new Promise(function(resolve, reject) {
965
- window._craftOTARollbackResolve = resolve;
966
- window._craftOTARollbackReject = reject;
1100
+ return window.__craftPromise('OTA rollback', '_craftOTARollbackResolve', '_craftOTARollbackReject', function() {
967
1101
  CraftAndroid.otaRollback();
968
1102
  });
969
1103
  },
@@ -973,12 +1107,10 @@ class CraftBridge(
973
1107
  catch(e) { return { version: '1.0.0', buildNumber: 1, hash: '', isOriginal: true, installedAt: '' }; }
974
1108
  },
975
1109
  onProgress: function(callback) {
976
- this._progressCallbacks.push(callback);
977
- window.addEventListener('craftOTAProgress', function(e) { callback(e.detail); });
1110
+ throw new Error('craft.ota.onProgress is not implemented on this platform');
978
1111
  },
979
1112
  onStatusChange: function(callback) {
980
- this._statusCallbacks.push(callback);
981
- window.addEventListener('craftOTAStatus', function(e) { callback(e.detail.status); });
1113
+ throw new Error('craft.ota.onStatusChange is not implemented on this platform');
982
1114
  }
983
1115
  },
984
1116
 
@@ -1222,14 +1354,14 @@ class CraftBridge(
1222
1354
  var legacySecureStore = craft.secureStore;
1223
1355
  craft.contractVersion = '1.0.0';
1224
1356
  craft.device = {
1225
- getInfo: function() { return Promise.resolve(craft.getDeviceInfo()); },
1357
+ getInfo: function() { return Promise.resolve().then(function() { return craft.getDeviceInfo(); }); },
1226
1358
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
1227
1359
  };
1228
1360
  craft.haptics = {
1229
- impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
1230
- notification: function(type) { craft.haptic(type === 'error' ? 'heavy' : 'light'); return Promise.resolve(); },
1231
- selection: function() { craft.haptic('selection'); return Promise.resolve(); },
1232
- vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
1361
+ impact: function(style) { return Promise.resolve().then(function() { craft.haptic(style || 'medium'); }); },
1362
+ notification: function(type) { return Promise.resolve().then(function() { craft.haptic(type === 'error' ? 'heavy' : 'light'); }); },
1363
+ selection: function() { return Promise.resolve().then(function() { craft.haptic('selection'); }); },
1364
+ vibrate: function(pattern) { return Promise.resolve().then(function() { craft.vibrate(pattern || []); }); }
1233
1365
  };
1234
1366
  craft.camera = {
1235
1367
  takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
@@ -1243,26 +1375,77 @@ class CraftBridge(
1243
1375
  authenticate: function(reason) { return legacyAuthenticate(reason); }
1244
1376
  };
1245
1377
  craft.secureStorage = {
1246
- set: function(key, value) { return Promise.resolve(legacySecureStore.set(key, value)).then(function() {}); },
1247
- get: function(key) { return Promise.resolve(legacySecureStore.get(key)); },
1248
- delete: function(key) { return Promise.resolve(legacySecureStore.remove(key)).then(function() {}); },
1249
- clear: function() { return Promise.resolve(CraftAndroid.secureClear()).then(function() {}); }
1378
+ set: function(key, value) { return Promise.resolve().then(function() { legacySecureStore.set(key, value); }); },
1379
+ get: function(key) { return Promise.resolve().then(function() { return legacySecureStore.get(key); }); },
1380
+ delete: function(key) { return Promise.resolve().then(function() { legacySecureStore.remove(key); }); },
1381
+ clear: function() { return Promise.resolve().then(function() { CraftAndroid.secureClear(); }); }
1382
+ };
1383
+ var permissionRequestSequence = 0;
1384
+ var permissionRequests = Object.create(null);
1385
+ var permissionRuntimeClosed = false;
1386
+ window.__craftPermissionResult = function(id, status) {
1387
+ if (permissionRuntimeClosed) return;
1388
+ var pending = permissionRequests[id];
1389
+ if (!pending) return;
1390
+ delete permissionRequests[id];
1391
+ clearTimeout(pending.timeout);
1392
+ pending.resolve(status || 'undetermined');
1393
+ };
1394
+ window.__craftRejectPermissionRequests = function(message) {
1395
+ permissionRuntimeClosed = true;
1396
+ Object.keys(permissionRequests).forEach(function(id) {
1397
+ var pending = permissionRequests[id];
1398
+ delete permissionRequests[id];
1399
+ clearTimeout(pending.timeout);
1400
+ pending.reject(new Error(message || 'Android bridge closed'));
1401
+ });
1402
+ };
1403
+ craft.permissions = {
1404
+ check: function(permission) {
1405
+ return Promise.resolve().then(function() { return CraftAndroid.checkPermission(String(permission)); });
1406
+ },
1407
+ request: function(permission) {
1408
+ if (permissionRuntimeClosed) {
1409
+ return Promise.reject(new Error('Android bridge is closed'));
1410
+ }
1411
+ return new Promise(function(resolve, reject) {
1412
+ permissionRequestSequence += 1;
1413
+ var id = permissionRequestSequence;
1414
+ var timeout = setTimeout(function() {
1415
+ delete permissionRequests[id];
1416
+ reject(new Error('Craft permission request timed out'));
1417
+ }, 30000);
1418
+ permissionRequests[id] = {resolve: resolve, timeout: timeout};
1419
+ try {
1420
+ CraftAndroid.requestPermission(String(permission), id);
1421
+ } catch (error) {
1422
+ clearTimeout(timeout);
1423
+ delete permissionRequests[id];
1424
+ reject(error);
1425
+ }
1426
+ });
1427
+ },
1428
+ openSettings: function() {
1429
+ return Promise.resolve().then(function() { CraftAndroid.openPermissionSettings(); });
1430
+ }
1250
1431
  };
1251
1432
  craft.location = {
1252
1433
  getCurrentPosition: craft.getCurrentPosition,
1253
1434
  watchPosition: craft.watchPosition,
1254
1435
  clearWatch: craft.clearWatch,
1255
- startRecording: function(options) { return Promise.resolve(JSON.parse(CraftAndroid.startLocationRecording(JSON.stringify(options || {})))); },
1256
- pauseRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.pauseLocationRecording())); },
1257
- resumeRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.resumeLocationRecording())); },
1258
- stopRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.stopLocationRecording())); },
1259
- getRecordingState: function() { return Promise.resolve(JSON.parse(CraftAndroid.getLocationRecordingState())); },
1260
- readRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.readLocationRecording())); }
1436
+ startRecording: function(options) { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.startLocationRecording(JSON.stringify(options || {}))); }); },
1437
+ pauseRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.pauseLocationRecording()); }); },
1438
+ resumeRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.resumeLocationRecording()); }); },
1439
+ stopRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.stopLocationRecording()); }); },
1440
+ getRecordingState: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.getLocationRecordingState()); }); },
1441
+ readRecording: function() { return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.readLocationRecording()); }); }
1261
1442
  };
1262
1443
  var shareApi = function(text, title) { return legacyShare(text, title); };
1263
1444
  shareApi.share = function(options) {
1264
1445
  options = options || {};
1265
- return Promise.resolve(legacyShare([options.text, options.url].filter(Boolean).join(' '), options.title || ''));
1446
+ return Promise.resolve().then(function() {
1447
+ return legacyShare([options.text, options.url].filter(Boolean).join(' '), options.title || '');
1448
+ });
1266
1449
  };
1267
1450
  craft.share = shareApi;
1268
1451
  craft.lifecycle = {
@@ -1287,14 +1470,27 @@ class CraftBridge(
1287
1470
  """.trimIndent()
1288
1471
 
1289
1472
  activity.runOnUiThread {
1290
- webView.evaluateJavascript(script, null)
1473
+ if (closed) return@runOnUiThread
1474
+ webView.evaluateJavascript(script) {
1475
+ if (closed) return@evaluateJavascript
1476
+ bridgeReady = true
1477
+ val events = pendingEvents.toList()
1478
+ pendingEvents.clear()
1479
+ events.forEach { (name, data) -> sendEvent(name, data) }
1480
+ }
1291
1481
  }
1292
1482
  }
1293
1483
 
1484
+ fun markBridgeLoading() {
1485
+ bridgeReady = false
1486
+ }
1487
+
1294
1488
  // ==================== Haptics ====================
1295
1489
 
1296
1490
  @JavascriptInterface
1297
1491
  fun haptic(style: String) {
1492
+ if (CraftNative.haptic(activity, style)) return
1493
+
1298
1494
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1299
1495
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
1300
1496
  vibratorManager.defaultVibrator
@@ -1325,15 +1521,18 @@ class CraftBridge(
1325
1521
 
1326
1522
  @JavascriptInterface
1327
1523
  fun startListening() {
1524
+ if (CraftNative.startListening(activity)) return
1525
+
1328
1526
  if (!SpeechRecognizer.isRecognitionAvailable(activity)) {
1329
1527
  sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available"))
1330
1528
  return
1331
1529
  }
1332
1530
 
1333
1531
  activity.runOnUiThread {
1532
+ if (closed) return@runOnUiThread
1334
1533
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
1335
1534
  != PackageManager.PERMISSION_GRANTED) {
1336
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1535
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1337
1536
  return@runOnUiThread
1338
1537
  }
1339
1538
 
@@ -1399,6 +1598,8 @@ class CraftBridge(
1399
1598
 
1400
1599
  @JavascriptInterface
1401
1600
  fun stopListening() {
1601
+ if (CraftNative.stopListening(activity)) return
1602
+
1402
1603
  activity.runOnUiThread {
1403
1604
  speechRecognizer?.stopListening()
1404
1605
  speechRecognizer?.destroy()
@@ -1411,6 +1612,8 @@ class CraftBridge(
1411
1612
 
1412
1613
  @JavascriptInterface
1413
1614
  fun share(text: String, title: String) {
1615
+ if (CraftNative.share(activity, text, title)) return
1616
+
1414
1617
  val intent = Intent(Intent.ACTION_SEND).apply {
1415
1618
  type = "text/plain"
1416
1619
  putExtra(Intent.EXTRA_TEXT, text)
@@ -1427,45 +1630,150 @@ class CraftBridge(
1427
1630
  fun openCamera() {
1428
1631
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
1429
1632
  != PackageManager.PERMISSION_GRANTED) {
1430
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), 101)
1633
+ rejectMediaPromise("_craftCamera", "Camera permission is required; retry after granting it")
1634
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.CAMERA), 101)
1431
1635
  return
1432
1636
  }
1433
1637
 
1434
- val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
1435
- activity.startActivityForResult(intent, REQUEST_CAMERA)
1638
+ // The Activity result still returns to handleImageResult below; Zig
1639
+ // only launches it, using the same request code that routes it here.
1640
+ if (CraftNative.openCamera(activity)) return
1641
+
1642
+ try {
1643
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
1644
+ activity.startActivityForResult(intent, REQUEST_CAMERA)
1645
+ } catch (error: Exception) {
1646
+ rejectMediaPromise("_craftCamera", error.message ?: "Camera could not be opened")
1647
+ }
1436
1648
  }
1437
1649
 
1438
1650
  @JavascriptInterface
1439
1651
  fun pickImage() {
1440
- val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
1441
- activity.startActivityForResult(intent, REQUEST_GALLERY)
1652
+ if (CraftNative.pickImage(activity)) return
1653
+
1654
+ try {
1655
+ val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
1656
+ activity.startActivityForResult(intent, REQUEST_GALLERY)
1657
+ } catch (error: Exception) {
1658
+ rejectMediaPromise("_craftGallery", error.message ?: "Image picker could not be opened")
1659
+ }
1442
1660
  }
1443
1661
 
1444
1662
  fun handleImageResult(requestCode: Int, resultCode: Int, data: Intent?) {
1663
+ if (closed) return
1664
+ val promise = if (requestCode == REQUEST_CAMERA) "_craftCamera" else "_craftGallery"
1445
1665
  if (resultCode != Activity.RESULT_OK) {
1446
- val event = if (requestCode == REQUEST_CAMERA) "_craftCameraReject" else "_craftGalleryReject"
1447
- activity.runOnUiThread {
1448
- webView.evaluateJavascript("window.$event && window.$event('Cancelled')", null)
1449
- }
1666
+ rejectMediaPromise(promise, "Cancelled")
1450
1667
  return
1451
1668
  }
1452
1669
 
1453
- val imageUri: String? = when (requestCode) {
1454
- REQUEST_CAMERA -> {
1455
- // For camera, we get a bitmap thumbnail
1456
- val bitmap = data?.extras?.get("data") as? Bitmap
1457
- bitmap?.let { "data:image/png;base64,${bitmapToBase64(it)}" }
1670
+ if (requestCode == REQUEST_CAMERA) {
1671
+ val bitmap = data?.extras?.get("data") as? Bitmap
1672
+ if (bitmap == null) {
1673
+ rejectMediaPromise(promise, "Camera returned no image")
1674
+ } else {
1675
+ resolveMediaPromise(promise, "data:image/png;base64,${bitmapToBase64(bitmap)}")
1458
1676
  }
1459
- REQUEST_GALLERY -> {
1460
- data?.data?.toString()
1677
+ return
1678
+ }
1679
+
1680
+ val uri = data?.data
1681
+ if (uri == null) {
1682
+ rejectMediaPromise(promise, "Gallery returned no image")
1683
+ return
1684
+ }
1685
+
1686
+ Thread {
1687
+ try {
1688
+ val bytes = activity.contentResolver.openInputStream(uri)?.use { it.readBytes() }
1689
+ ?: throw IllegalStateException("Selected image could not be opened")
1690
+ val mimeType = activity.contentResolver.getType(uri) ?: "image/jpeg"
1691
+ val encoded = Base64.encodeToString(bytes, Base64.NO_WRAP)
1692
+ resolveMediaPromise(promise, "data:$mimeType;base64,$encoded")
1693
+ } catch (e: Exception) {
1694
+ rejectMediaPromise(promise, e.message ?: "Selected image could not be read")
1461
1695
  }
1462
- else -> null
1696
+ }.start()
1697
+ }
1698
+
1699
+ private fun resolveMediaPromise(promise: String, value: String) {
1700
+ evaluatePromiseJavascript(
1701
+ "window.${promise}Resolve && window.${promise}Resolve(${jsQuote(value)})"
1702
+ )
1703
+ }
1704
+
1705
+ private fun rejectMediaPromise(promise: String, message: String) {
1706
+ evaluatePromiseJavascript(
1707
+ "window.${promise}Reject && window.${promise}Reject(${jsQuote(message)})"
1708
+ )
1709
+ }
1710
+
1711
+ private fun handleFilePickerResult(resultCode: Int, data: Intent?) {
1712
+ if (closed) return
1713
+ val uri = data?.data
1714
+ if (resultCode != Activity.RESULT_OK || uri == null) {
1715
+ rejectFilePicker("Cancelled")
1716
+ return
1463
1717
  }
1464
1718
 
1465
- val event = if (requestCode == REQUEST_CAMERA) "_craftCameraResolve" else "_craftGalleryResolve"
1466
- activity.runOnUiThread {
1467
- webView.evaluateJavascript("window.$event && window.$event('$imageUri')", null)
1719
+ Thread {
1720
+ try {
1721
+ val bytes = activity.contentResolver.openInputStream(uri)?.use { it.readBytes() }
1722
+ ?: throw IllegalStateException("Selected file could not be opened")
1723
+ var name = uri.lastPathSegment ?: "file"
1724
+ activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
1725
+ if (cursor.moveToFirst()) {
1726
+ name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
1727
+ }
1728
+ }
1729
+ val result = JSONObject().apply {
1730
+ put("name", name)
1731
+ put("data", Base64.encodeToString(bytes, Base64.NO_WRAP))
1732
+ put("mimeType", activity.contentResolver.getType(uri) ?: "application/octet-stream")
1733
+ }
1734
+ evaluatePromiseJavascript("window._craftFileResolve && window._craftFileResolve($result)")
1735
+ } catch (e: Exception) {
1736
+ rejectFilePicker(e.message ?: "Selected file could not be read")
1737
+ }
1738
+ }.start()
1739
+ }
1740
+
1741
+ private fun rejectFilePicker(message: String) {
1742
+ evaluatePromiseJavascript(
1743
+ "window._craftFileReject && window._craftFileReject(${jsQuote(message)})"
1744
+ )
1745
+ }
1746
+
1747
+ private fun handleVideoResult(resultCode: Int, data: Intent?) {
1748
+ if (closed) return
1749
+ val uri = data?.data
1750
+ if (resultCode != Activity.RESULT_OK) {
1751
+ evaluatePromiseJavascript("window._craftVideoReject && window._craftVideoReject('Cancelled')")
1752
+ return
1753
+ }
1754
+ if (uri == null) {
1755
+ evaluatePromiseJavascript(
1756
+ "window._craftVideoReject && window._craftVideoReject('Video capture returned no file')"
1757
+ )
1758
+ return
1468
1759
  }
1760
+
1761
+ Thread {
1762
+ try {
1763
+ val bytes = activity.contentResolver.openInputStream(uri)?.use { it.readBytes() }
1764
+ ?: throw IllegalStateException("Recorded video could not be opened")
1765
+ val mimeType = activity.contentResolver.getType(uri) ?: "video/mp4"
1766
+ val encoded = Base64.encodeToString(bytes, Base64.NO_WRAP)
1767
+ val result = "data:$mimeType;base64,$encoded"
1768
+ evaluatePromiseJavascript(
1769
+ "window._craftVideoResolve && window._craftVideoResolve(${jsQuote(result)})"
1770
+ )
1771
+ } catch (e: Exception) {
1772
+ evaluatePromiseJavascript(
1773
+ "window._craftVideoReject && window._craftVideoReject(${jsQuote(e.message ?: "Recorded video could not be read")})"
1774
+ )
1775
+ }
1776
+ }.start()
1469
1777
  }
1470
1778
 
1471
1779
  private fun bitmapToBase64(bitmap: Bitmap): String {
@@ -1484,49 +1792,65 @@ class CraftBridge(
1484
1792
 
1485
1793
  @JavascriptInterface
1486
1794
  fun authenticate(reason: String) {
1795
+ // The prompt and its Java callback live in CraftNative; every result
1796
+ // comes back through Zig, which settles the existing page promise.
1797
+ if (CraftNative.authenticate(activity, reason)) return
1798
+
1799
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
1800
+
1801
+ fun resolveBiometricRequest() {
1802
+ if (!settled.compareAndSet(false, true) || closed) return
1803
+ biometricPrompt = null
1804
+ evaluatePromiseJavascript("window._craftBiometricResolve && window._craftBiometricResolve(true)")
1805
+ }
1806
+
1807
+ fun rejectBiometricRequest(message: String) {
1808
+ if (!settled.compareAndSet(false, true) || closed) return
1809
+ biometricPrompt = null
1810
+ evaluatePromiseJavascript(
1811
+ "window._craftBiometricReject && window._craftBiometricReject(${jsQuote(message)})"
1812
+ )
1813
+ }
1814
+
1487
1815
  if (activity !is FragmentActivity) {
1488
- activity.runOnUiThread {
1489
- webView.evaluateJavascript(
1490
- "window._craftBiometricReject && window._craftBiometricReject('Activity not supported')",
1491
- null
1492
- )
1493
- }
1816
+ rejectBiometricRequest("Activity not supported")
1494
1817
  return
1495
1818
  }
1496
1819
 
1497
1820
  activity.runOnUiThread {
1498
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
1499
- .setTitle("Authenticate")
1500
- .setSubtitle(reason)
1501
- .setNegativeButtonText("Cancel")
1502
- .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
1503
- .build()
1504
-
1505
- val biometricPrompt = BiometricPrompt(
1506
- activity as FragmentActivity,
1507
- mainExecutor,
1508
- object : BiometricPrompt.AuthenticationCallback() {
1509
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
1510
- webView.evaluateJavascript(
1511
- "window._craftBiometricResolve && window._craftBiometricResolve(true)",
1512
- null
1513
- )
1514
- }
1821
+ if (closed) return@runOnUiThread
1822
+ try {
1823
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
1824
+ .setTitle("Authenticate")
1825
+ .setSubtitle(reason)
1826
+ .setNegativeButtonText("Cancel")
1827
+ .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
1828
+ .build()
1829
+
1830
+ runCatching { biometricPrompt?.cancelAuthentication() }
1831
+ val prompt = BiometricPrompt(
1832
+ activity as FragmentActivity,
1833
+ mainExecutor,
1834
+ object : BiometricPrompt.AuthenticationCallback() {
1835
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
1836
+ resolveBiometricRequest()
1837
+ }
1515
1838
 
1516
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
1517
- webView.evaluateJavascript(
1518
- "window._craftBiometricReject && window._craftBiometricReject('$errString')",
1519
- null
1520
- )
1521
- }
1839
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
1840
+ rejectBiometricRequest(errString.toString())
1841
+ }
1522
1842
 
1523
- override fun onAuthenticationFailed() {
1524
- // Don't reject yet - user can retry
1843
+ override fun onAuthenticationFailed() {
1844
+ // Don't reject yet - user can retry
1845
+ }
1525
1846
  }
1526
- }
1527
- )
1847
+ )
1528
1848
 
1529
- biometricPrompt.authenticate(promptInfo)
1849
+ biometricPrompt = prompt
1850
+ prompt.authenticate(promptInfo)
1851
+ } catch (error: Exception) {
1852
+ rejectBiometricRequest(error.message ?: "Biometric authentication failed")
1853
+ }
1530
1854
  }
1531
1855
  }
1532
1856
 
@@ -1541,6 +1865,8 @@ class CraftBridge(
1541
1865
 
1542
1866
  @JavascriptInterface
1543
1867
  fun secureSet(key: String, value: String): Boolean {
1868
+ if (CraftNative.secureSet(securePrefs, key, value)) return true
1869
+
1544
1870
  return try {
1545
1871
  securePrefs.edit().putString(key, value).apply()
1546
1872
  true
@@ -1551,6 +1877,14 @@ class CraftBridge(
1551
1877
 
1552
1878
  @JavascriptInterface
1553
1879
  fun secureGet(key: String): String? {
1880
+ // Three answers, not two: Declined falls through, NotFound is a real
1881
+ // null, Found is a real value that may be the empty string.
1882
+ when (val read = CraftNative.secureGet(securePrefs, key)) {
1883
+ is CraftNative.SecureRead.Found -> return read.value
1884
+ CraftNative.SecureRead.NotFound -> return null
1885
+ CraftNative.SecureRead.Declined -> Unit
1886
+ }
1887
+
1554
1888
  return try {
1555
1889
  securePrefs.getString(key, null)
1556
1890
  } catch (e: Exception) {
@@ -1560,6 +1894,8 @@ class CraftBridge(
1560
1894
 
1561
1895
  @JavascriptInterface
1562
1896
  fun secureRemove(key: String): Boolean {
1897
+ if (CraftNative.secureRemove(securePrefs, key)) return true
1898
+
1563
1899
  return try {
1564
1900
  securePrefs.edit().remove(key).apply()
1565
1901
  true
@@ -1570,6 +1906,8 @@ class CraftBridge(
1570
1906
 
1571
1907
  @JavascriptInterface
1572
1908
  fun secureClear(): Boolean {
1909
+ if (CraftNative.secureClear(securePrefs)) return true
1910
+
1573
1911
  return try {
1574
1912
  securePrefs.edit().clear().apply()
1575
1913
  true
@@ -1582,104 +1920,271 @@ class CraftBridge(
1582
1920
 
1583
1921
  @JavascriptInterface
1584
1922
  fun log(message: String) {
1923
+ if (CraftNative.log(message)) return
1585
1924
  android.util.Log.d("CraftBridge", message)
1586
1925
  }
1587
1926
 
1588
1927
  // ==================== Geolocation ====================
1589
1928
 
1590
- @JavascriptInterface
1591
- fun getCurrentPosition(optionsJson: String) {
1592
- if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
1593
- != PackageManager.PERMISSION_GRANTED) {
1594
- ActivityCompat.requestPermissions(
1595
- activity,
1596
- arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
1597
- REQUEST_LOCATION
1598
- )
1599
- activity.runOnUiThread {
1600
- webView.evaluateJavascript(
1601
- "window._craftLocationReject && window._craftLocationReject({code: 1, message: 'Permission denied'})",
1602
- null
1603
- )
1604
- }
1605
- return
1606
- }
1607
-
1608
- fusedLocationClient = LocationServices.getFusedLocationProviderClient(activity)
1609
- fusedLocationClient?.lastLocation?.addOnSuccessListener { location: Location? ->
1610
- if (location != null) {
1611
- val json = JSONObject().apply {
1612
- put("latitude", location.latitude)
1613
- put("longitude", location.longitude)
1614
- put("accuracy", location.accuracy)
1615
- put("altitude", location.altitude)
1616
- put("speed", location.speed)
1617
- put("heading", location.bearing)
1618
- put("timestamp", location.time)
1619
- }
1620
- activity.runOnUiThread {
1621
- webView.evaluateJavascript(
1622
- "window._craftLocationResolve && window._craftLocationResolve($json)",
1623
- null
1624
- )
1625
- }
1626
- } else {
1627
- // Request fresh location if last location is null
1628
- requestFreshLocation()
1629
- }
1630
- }?.addOnFailureListener { e ->
1631
- activity.runOnUiThread {
1632
- webView.evaluateJavascript(
1633
- "window._craftLocationReject && window._craftLocationReject({code: 2, message: '${e.message}'})",
1634
- null
1635
- )
1636
- }
1637
- }
1929
+ private fun nativePermissionStatus(name: String): String {
1930
+ return CraftPermissionPolicy.status(name, Build.VERSION.SDK_INT, ::isPermissionGranted)
1638
1931
  }
1639
1932
 
1640
- private fun requestFreshLocation() {
1641
- if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
1642
- return
1643
- }
1933
+ private fun isPermissionGranted(permission: String): Boolean =
1934
+ ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
1644
1935
 
1645
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
1646
- .setWaitForAccurateLocation(false)
1647
- .setMinUpdateIntervalMillis(500)
1648
- .setMaxUpdateDelayMillis(1000)
1649
- .setMaxUpdates(1)
1650
- .build()
1936
+ private fun hasForegroundLocationPermission(): Boolean =
1937
+ CraftPermissionPolicy.foregroundLocationIsGranted(::isPermissionGranted)
1651
1938
 
1652
- val callback = object : LocationCallback() {
1653
- override fun onLocationResult(result: LocationResult) {
1654
- fusedLocationClient?.removeLocationUpdates(this)
1655
- val location = result.lastLocation
1656
- if (location != null) {
1657
- val json = JSONObject().apply {
1658
- put("latitude", location.latitude)
1659
- put("longitude", location.longitude)
1660
- put("accuracy", location.accuracy)
1661
- put("altitude", location.altitude)
1662
- put("speed", location.speed)
1663
- put("heading", location.bearing)
1664
- put("timestamp", location.time)
1665
- }
1666
- activity.runOnUiThread {
1667
- webView.evaluateJavascript(
1668
- "window._craftLocationResolve && window._craftLocationResolve($json)",
1669
- null
1670
- )
1671
- }
1672
- }
1673
- }
1939
+ private fun claimPermissionRequestCode(): Int? {
1940
+ var requestCode = nextPermissionRequestCode
1941
+ while (pendingPermissionRequests.containsKey(requestCode)) {
1942
+ requestCode += 1
1943
+ if (requestCode > PERMISSION_REQUEST_END) requestCode = PERMISSION_REQUEST_START
1944
+ if (requestCode == nextPermissionRequestCode) return null
1945
+ }
1946
+ nextPermissionRequestCode = if (requestCode == PERMISSION_REQUEST_END) {
1947
+ PERMISSION_REQUEST_START
1948
+ } else {
1949
+ requestCode + 1
1674
1950
  }
1951
+ return requestCode
1952
+ }
1675
1953
 
1676
- fusedLocationClient?.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
1954
+ private fun enqueuePermissionRequest(
1955
+ permission: String,
1956
+ callbackId: Int,
1957
+ startingAt: Int
1958
+ ): Boolean {
1959
+ val request = CraftPermissionPolicy.nextRequest(
1960
+ permission,
1961
+ Build.VERSION.SDK_INT,
1962
+ startingAt,
1963
+ ::isPermissionGranted
1964
+ ) ?: return false
1965
+ val requestCode = claimPermissionRequestCode() ?: return false
1966
+ pendingPermissionRequests[requestCode] = PendingPermissionRequest(
1967
+ callbackId,
1968
+ permission,
1969
+ request.groupIndex
1970
+ )
1971
+ return try {
1972
+ ActivityCompat.requestPermissions(activity, request.permissions, requestCode)
1973
+ true
1974
+ } catch (error: Exception) {
1975
+ pendingPermissionRequests.remove(requestCode)
1976
+ false
1977
+ }
1677
1978
  }
1678
1979
 
1679
1980
  @JavascriptInterface
1680
- fun watchPosition(optionsJson: String): Int {
1681
- if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
1682
- != PackageManager.PERMISSION_GRANTED) {
1981
+ fun checkPermission(permission: String): String = nativePermissionStatus(permission)
1982
+
1983
+ @JavascriptInterface
1984
+ fun requestPermission(permission: String, callbackId: Int) {
1985
+ activity.runOnUiThread {
1986
+ if (closed) return@runOnUiThread
1987
+ val status = nativePermissionStatus(permission)
1988
+ if (status == "granted" || !enqueuePermissionRequest(permission, callbackId, 0)) {
1989
+ deliverPermissionResult(callbackId, status)
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ @JavascriptInterface
1995
+ fun openPermissionSettings() {
1996
+ activity.runOnUiThread {
1997
+ val intent = Intent(
1998
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
1999
+ Uri.fromParts("package", activity.packageName, null)
2000
+ )
2001
+ activity.startActivity(intent)
2002
+ }
2003
+ }
2004
+
2005
+ fun onRequestPermissionsResult(requestCode: Int): Boolean {
2006
+ if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END
2007
+ val pending = pendingPermissionRequests.remove(requestCode) ?: return false
2008
+ // Re-read package state instead of interpreting the parallel callback
2009
+ // arrays. In particular, an approximate-location choice returns a
2010
+ // denied fine result and a granted coarse result, which is still a
2011
+ // granted `location` permission in Craft's contract.
2012
+ val status = nativePermissionStatus(pending.permission)
2013
+ val completedGroup = CraftPermissionPolicy.groupIsGranted(
2014
+ pending.permission,
2015
+ Build.VERSION.SDK_INT,
2016
+ pending.groupIndex,
2017
+ ::isPermissionGranted
2018
+ )
2019
+ if (status != "granted" && completedGroup && enqueuePermissionRequest(
2020
+ pending.permission,
2021
+ pending.callbackId,
2022
+ pending.groupIndex + 1
2023
+ )) return true
2024
+
2025
+ deliverPermissionResult(pending.callbackId, status)
2026
+ return true
2027
+ }
2028
+
2029
+ private fun deliverPermissionResult(callbackId: Int, status: String) {
2030
+ if (closed) return
2031
+ activity.runOnUiThread {
2032
+ if (closed) return@runOnUiThread
2033
+ webView.evaluateJavascript(
2034
+ "window.__craftPermissionResult && window.__craftPermissionResult($callbackId, ${jsQuote(status)})",
2035
+ null
2036
+ )
2037
+ }
2038
+ }
2039
+
2040
+ @JavascriptInterface
2041
+ fun getCurrentPosition(optionsJson: String) {
2042
+ // The task listeners and the fresh-location callback are
2043
+ // CraftNative's, because they are Java interfaces. fusedLocationClient
2044
+ // below is independent from the callbacks owned by watchPosition, so
2045
+ // a one-shot lookup cannot overwrite or clear a continuous watch.
2046
+ val requestId = currentPositionSequence.incrementAndGet()
2047
+ oneShotLocationCallbacks.forEach { callback ->
2048
+ runCatching { fusedLocationClient?.removeLocationUpdates(callback) }
2049
+ }
2050
+ oneShotLocationCallbacks.clear()
2051
+ oneShotLocationTimeouts.values.forEach { timeout ->
2052
+ oneShotLocationHandler.removeCallbacks(timeout)
2053
+ }
2054
+ oneShotLocationTimeouts.clear()
2055
+
2056
+ if (CraftNative.getCurrentPosition(activity)) return
2057
+
2058
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
2059
+
2060
+ fun resolveLocationRequest(location: Location) {
2061
+ if (requestId != currentPositionSequence.get()) return
2062
+ if (!settled.compareAndSet(false, true) || closed) return
2063
+ val json = JSONObject().apply {
2064
+ put("latitude", location.latitude)
2065
+ put("longitude", location.longitude)
2066
+ put("accuracy", location.accuracy)
2067
+ put("altitude", location.altitude)
2068
+ put("speed", location.speed)
2069
+ put("heading", location.bearing)
2070
+ put("timestamp", location.time)
2071
+ }
2072
+ evaluatePromiseJavascript(
2073
+ "window._craftLocationResolve && window._craftLocationResolve($json)"
2074
+ )
2075
+ }
2076
+
2077
+ fun rejectLocationRequest(message: String, code: Int = 2) {
2078
+ if (requestId != currentPositionSequence.get()) return
2079
+ if (!settled.compareAndSet(false, true) || closed) return
2080
+ rejectLocation(message, code)
2081
+ }
2082
+
2083
+ if (!hasForegroundLocationPermission()) {
2084
+ rejectLocationRequest("Permission denied", 1)
2085
+ requestPermissionsBestEffort(
2086
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
2087
+ REQUEST_LOCATION
2088
+ )
2089
+ return
2090
+ }
2091
+
2092
+ if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(activity) != ConnectionResult.SUCCESS) {
2093
+ rejectLocationRequest("Google Play Services is unavailable")
2094
+ return
2095
+ }
2096
+
2097
+ try {
2098
+ val client = LocationServices.getFusedLocationProviderClient(activity)
2099
+ fusedLocationClient = client
2100
+ client.lastLocation.addOnSuccessListener { location: Location? ->
2101
+ if (requestId != currentPositionSequence.get()) return@addOnSuccessListener
2102
+ if (location != null) {
2103
+ resolveLocationRequest(location)
2104
+ } else {
2105
+ requestFreshLocation(
2106
+ client,
2107
+ requestId,
2108
+ ::resolveLocationRequest,
2109
+ { message -> rejectLocationRequest(message) }
2110
+ )
2111
+ }
2112
+ }.addOnFailureListener { error -> rejectLocationRequest(error.message ?: "Location lookup failed") }
2113
+ } catch (error: Exception) {
2114
+ rejectLocationRequest(error.message ?: "Location lookup failed")
2115
+ }
2116
+ }
2117
+
2118
+ private fun requestFreshLocation(
2119
+ client: FusedLocationProviderClient,
2120
+ requestId: Long,
2121
+ resolve: (Location) -> Unit,
2122
+ reject: (String) -> Unit
2123
+ ) {
2124
+ if (!hasForegroundLocationPermission()) {
2125
+ reject("Location permission was revoked")
2126
+ return
2127
+ }
2128
+
2129
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
2130
+ .setWaitForAccurateLocation(false)
2131
+ .setMinUpdateIntervalMillis(500)
2132
+ .setMaxUpdateDelayMillis(1000)
2133
+ .setMaxUpdates(1)
2134
+ .build()
2135
+
2136
+ val callback = object : LocationCallback() {
2137
+ override fun onLocationResult(result: LocationResult) {
2138
+ clearOneShotLocationCallback(client, this)
2139
+ if (requestId != currentPositionSequence.get()) return
2140
+ result.lastLocation?.let(resolve)
2141
+ ?: reject("Location provider returned no position")
2142
+ }
2143
+ }
2144
+
2145
+ oneShotLocationCallbacks.add(callback)
2146
+ val timeout = Runnable {
2147
+ if (!oneShotLocationCallbacks.contains(callback)) return@Runnable
2148
+ clearOneShotLocationCallback(client, callback)
2149
+ reject("Location request timed out; provider returned no position")
2150
+ }
2151
+ oneShotLocationTimeouts[callback] = timeout
2152
+ oneShotLocationHandler.postDelayed(timeout, LOCATION_REQUEST_TIMEOUT_MS)
2153
+ try {
2154
+ client.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
2155
+ .addOnFailureListener { error ->
2156
+ clearOneShotLocationCallback(client, callback)
2157
+ reject(error.message ?: "Location request failed")
2158
+ }
2159
+ } catch (error: Exception) {
2160
+ clearOneShotLocationCallback(client, callback)
2161
+ reject(error.message ?: "Location request failed")
2162
+ }
2163
+ }
2164
+
2165
+ private fun clearOneShotLocationCallback(
2166
+ client: FusedLocationProviderClient,
2167
+ callback: LocationCallback
2168
+ ) {
2169
+ runCatching { client.removeLocationUpdates(callback) }
2170
+ oneShotLocationCallbacks.remove(callback)
2171
+ oneShotLocationTimeouts.remove(callback)?.let { timeout ->
2172
+ oneShotLocationHandler.removeCallbacks(timeout)
2173
+ }
2174
+ }
2175
+
2176
+ private fun rejectLocation(message: String, code: Int = 2) {
2177
+ evaluatePromiseJavascript(
2178
+ "window._craftLocationReject && window._craftLocationReject({code: $code, message: ${jsQuote(message)}})"
2179
+ )
2180
+ }
2181
+
2182
+ @JavascriptInterface
2183
+ fun watchPosition(optionsJson: String): Int {
2184
+ if (!hasForegroundLocationPermission()) {
2185
+ return -1
2186
+ }
2187
+ if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(activity) != ConnectionResult.SUCCESS) {
1683
2188
  return -1
1684
2189
  }
1685
2190
 
@@ -1690,7 +2195,7 @@ class CraftBridge(
1690
2195
  .setMinUpdateIntervalMillis(2000)
1691
2196
  .build()
1692
2197
 
1693
- locationCallback = object : LocationCallback() {
2198
+ val callback = object : LocationCallback() {
1694
2199
  override fun onLocationResult(result: LocationResult) {
1695
2200
  val location = result.lastLocation ?: return
1696
2201
  val json = JSONObject().apply {
@@ -1702,17 +2207,15 @@ class CraftBridge(
1702
2207
  put("heading", location.bearing)
1703
2208
  put("timestamp", location.time)
1704
2209
  }
1705
- activity.runOnUiThread {
1706
- webView.evaluateJavascript(
1707
- "window._craftLocationWatch_$currentWatchId && window._craftLocationWatch_$currentWatchId($json)",
1708
- null
1709
- )
1710
- }
2210
+ evaluateJavascriptUnlessClosed(
2211
+ "window._craftLocationWatch_$currentWatchId && window._craftLocationWatch_$currentWatchId($json)"
2212
+ )
1711
2213
  }
1712
2214
  }
1713
2215
 
1714
- if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
1715
- fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback!!, Looper.getMainLooper())
2216
+ if (hasForegroundLocationPermission()) {
2217
+ fusedLocationClient?.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
2218
+ locationCallbacks[currentWatchId] = callback
1716
2219
  }
1717
2220
 
1718
2221
  return currentWatchId
@@ -1720,15 +2223,18 @@ class CraftBridge(
1720
2223
 
1721
2224
  @JavascriptInterface
1722
2225
  fun clearWatch(watchId: Int) {
1723
- locationCallback?.let {
2226
+ locationCallbacks.remove(watchId)?.let {
1724
2227
  fusedLocationClient?.removeLocationUpdates(it)
1725
2228
  }
1726
- locationCallback = null
1727
2229
  }
1728
2230
 
1729
2231
  @JavascriptInterface
1730
2232
  fun startLocationRecording(optionsJson: String): String {
1731
- if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
2233
+ // Null is "Zig declined". A permission-denied answer is not null — it
2234
+ // is a state object saying so, and returning it here is the answer.
2235
+ CraftNative.startLocationRecording(activity)?.let { return it }
2236
+
2237
+ if (!hasForegroundLocationPermission()) {
1732
2238
  return JSONObject().apply {
1733
2239
  put("id", JSONObject.NULL)
1734
2240
  put("active", false)
@@ -1748,12 +2254,16 @@ class CraftBridge(
1748
2254
 
1749
2255
  @JavascriptInterface
1750
2256
  fun pauseLocationRecording(): String {
2257
+ CraftNative.pauseLocationRecording(activity)?.let { return it }
2258
+
1751
2259
  CraftLocationRecordingStore.setPaused(activity, true)
1752
2260
  return CraftLocationRecordingStore.state(activity).toString()
1753
2261
  }
1754
2262
 
1755
2263
  @JavascriptInterface
1756
2264
  fun resumeLocationRecording(): String {
2265
+ CraftNative.resumeLocationRecording(activity)?.let { return it }
2266
+
1757
2267
  CraftLocationRecordingStore.setPaused(activity, false)
1758
2268
  if (CraftLocationRecordingStore.isActive(activity)) {
1759
2269
  ContextCompat.startForegroundService(
@@ -1766,6 +2276,8 @@ class CraftBridge(
1766
2276
 
1767
2277
  @JavascriptInterface
1768
2278
  fun stopLocationRecording(): String {
2279
+ CraftNative.stopLocationRecording(activity)?.let { return it }
2280
+
1769
2281
  CraftLocationRecordingStore.stop(activity)
1770
2282
  val result = CraftLocationRecordingStore.state(activity, includeLocations = true).toString()
1771
2283
  activity.stopService(Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP))
@@ -1773,15 +2285,29 @@ class CraftBridge(
1773
2285
  }
1774
2286
 
1775
2287
  @JavascriptInterface
1776
- fun getLocationRecordingState(): String = CraftLocationRecordingStore.state(activity).toString()
2288
+ fun getLocationRecordingState(): String {
2289
+ // Null is "Zig declined", not "no recording" — an absent recording is
2290
+ // a state object saying so, and returning it here is the answer.
2291
+ CraftNative.getLocationRecordingState(activity)?.let { return it }
2292
+
2293
+ return CraftLocationRecordingStore.state(activity).toString()
2294
+ }
1777
2295
 
1778
2296
  @JavascriptInterface
1779
- fun readLocationRecording(): String = CraftLocationRecordingStore.locations(activity).toString()
2297
+ fun readLocationRecording(): String {
2298
+ CraftNative.readLocationRecording(activity)?.let { return it }
2299
+
2300
+ return CraftLocationRecordingStore.locations(activity).toString()
2301
+ }
1780
2302
 
1781
2303
  // ==================== Clipboard ====================
1782
2304
 
1783
2305
  @JavascriptInterface
1784
2306
  fun clipboardRead(): String {
2307
+ // Null is "Zig declined", not "empty" — an empty clipboard comes back
2308
+ // as "" and returns here rather than falling through to be read twice.
2309
+ CraftNative.clipboardRead(activity)?.let { return it }
2310
+
1785
2311
  val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
1786
2312
  val clip = clipboard.primaryClip
1787
2313
  return if (clip != null && clip.itemCount > 0) {
@@ -1793,6 +2319,8 @@ class CraftBridge(
1793
2319
 
1794
2320
  @JavascriptInterface
1795
2321
  fun clipboardWrite(text: String): Boolean {
2322
+ if (CraftNative.clipboardWrite(activity, text)) return true
2323
+
1796
2324
  return try {
1797
2325
  val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
1798
2326
  val clip = ClipData.newPlainText("Craft", text)
@@ -1807,6 +2335,11 @@ class CraftBridge(
1807
2335
 
1808
2336
  @JavascriptInterface
1809
2337
  fun getDeviceInfo(): String {
2338
+ // The Zig runtime first, the body below when it declines. Null means
2339
+ // this build does not serve the action natively — a shim-only app, or
2340
+ // one whose libcraft.so failed to bind — and is not an error.
2341
+ CraftNative.getDeviceInfo(activity)?.let { return it }
2342
+
1810
2343
  val metrics = DisplayMetrics()
1811
2344
  @Suppress("DEPRECATION")
1812
2345
  activity.windowManager.defaultDisplay.getMetrics(metrics)
@@ -1837,32 +2370,12 @@ class CraftBridge(
1837
2370
  }.toString()
1838
2371
  }
1839
2372
 
1840
- // ==================== App Badge ====================
1841
-
1842
- @JavascriptInterface
1843
- fun setBadge(count: Int) {
1844
- // Android doesn't have native badge support, but we can use notification channels
1845
- // This is a simplified implementation - for full support, use ShortcutBadger library
1846
- try {
1847
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1848
- val notificationManager = NotificationManagerCompat.from(activity)
1849
- // Badge is typically shown via notification count
1850
- // Full implementation would require creating a notification
1851
- }
1852
- } catch (e: Exception) {
1853
- log("Badge not supported: ${e.message}")
1854
- }
1855
- }
1856
-
1857
- @JavascriptInterface
1858
- fun clearBadge() {
1859
- setBadge(0)
1860
- }
1861
-
1862
2373
  // ==================== Network Status ====================
1863
2374
 
1864
2375
  @JavascriptInterface
1865
2376
  fun getNetworkStatus(): String {
2377
+ CraftNative.getNetworkStatus(activity)?.let { return it }
2378
+
1866
2379
  val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1867
2380
  val network = connectivityManager.activeNetwork
1868
2381
  val capabilities = connectivityManager.getNetworkCapabilities(network)
@@ -1885,9 +2398,16 @@ class CraftBridge(
1885
2398
 
1886
2399
  @JavascriptInterface
1887
2400
  fun startNetworkMonitoring() {
2401
+ // The callback object is CraftNative's, because JNI cannot subclass
2402
+ // an abstract Java class — what Zig owns is what happens when it
2403
+ // fires. Returning here keeps this file's networkCallback null, which
2404
+ // is why stopNetworkMonitoring has to reach the same way.
2405
+ if (CraftNative.startNetworkMonitoring(activity)) return
2406
+ if (networkCallback != null) return
2407
+
1888
2408
  val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1889
2409
 
1890
- networkCallback = object : ConnectivityManager.NetworkCallback() {
2410
+ val callback = object : ConnectivityManager.NetworkCallback() {
1891
2411
  override fun onAvailable(network: Network) {
1892
2412
  sendNetworkChange()
1893
2413
  }
@@ -1905,11 +2425,14 @@ class CraftBridge(
1905
2425
  .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1906
2426
  .build()
1907
2427
 
1908
- connectivityManager.registerNetworkCallback(request, networkCallback!!)
2428
+ connectivityManager.registerNetworkCallback(request, callback)
2429
+ networkCallback = callback
1909
2430
  }
1910
2431
 
1911
2432
  @JavascriptInterface
1912
2433
  fun stopNetworkMonitoring() {
2434
+ if (CraftNative.stopNetworkMonitoring(activity)) return
2435
+
1913
2436
  val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1914
2437
  networkCallback?.let {
1915
2438
  connectivityManager.unregisterNetworkCallback(it)
@@ -1919,42 +2442,49 @@ class CraftBridge(
1919
2442
 
1920
2443
  private fun sendNetworkChange() {
1921
2444
  val status = getNetworkStatus()
1922
- activity.runOnUiThread {
1923
- webView.evaluateJavascript(
1924
- "window._craftNetworkChangeCallback && window._craftNetworkChangeCallback($status)",
1925
- null
1926
- )
1927
- }
2445
+ evaluateJavascriptUnlessClosed(
2446
+ "window._craftNetworkChangeCallback && window._craftNetworkChangeCallback($status)"
2447
+ )
1928
2448
  }
1929
2449
 
1930
2450
  // ==================== App Review ====================
1931
2451
 
1932
2452
  @JavascriptInterface
1933
2453
  fun requestReview() {
1934
- val reviewManager = ReviewManagerFactory.create(activity)
1935
- val request = reviewManager.requestReviewFlow()
1936
-
1937
- request.addOnCompleteListener { task ->
1938
- if (task.isSuccessful) {
1939
- val reviewInfo = task.result
1940
- val flow = reviewManager.launchReviewFlow(activity, reviewInfo)
1941
- flow.addOnCompleteListener {
1942
- activity.runOnUiThread {
1943
- webView.evaluateJavascript(
1944
- "window._craftReviewResolve && window._craftReviewResolve(true)",
1945
- null
1946
- )
2454
+ if (CraftNative.requestReview(activity)) return
2455
+
2456
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
2457
+
2458
+ fun resolveReviewRequest() {
2459
+ if (!settled.compareAndSet(false, true) || closed) return
2460
+ evaluatePromiseJavascript("window._craftReviewResolve && window._craftReviewResolve(true)")
2461
+ }
2462
+
2463
+ fun rejectReviewRequest(message: String) {
2464
+ if (!settled.compareAndSet(false, true) || closed) return
2465
+ evaluatePromiseJavascript(
2466
+ "window._craftReviewReject && window._craftReviewReject(${jsQuote(message)})"
2467
+ )
2468
+ }
2469
+
2470
+ try {
2471
+ val reviewManager = ReviewManagerFactory.create(activity)
2472
+ val request = reviewManager.requestReviewFlow()
2473
+
2474
+ request.addOnCompleteListener { task ->
2475
+ if (task.isSuccessful) {
2476
+ try {
2477
+ reviewManager.launchReviewFlow(activity, task.result)
2478
+ .addOnCompleteListener { resolveReviewRequest() }
2479
+ } catch (error: Exception) {
2480
+ rejectReviewRequest(error.message ?: "Review flow failed")
1947
2481
  }
1948
- }
1949
- } else {
1950
- activity.runOnUiThread {
1951
- val message = JSONObject.quote(task.exception?.message ?: "Review flow failed")
1952
- webView.evaluateJavascript(
1953
- "window._craftReviewReject && window._craftReviewReject($message)",
1954
- null
1955
- )
2482
+ } else {
2483
+ rejectReviewRequest(task.exception?.message ?: "Review flow failed")
1956
2484
  }
1957
2485
  }
2486
+ } catch (error: Exception) {
2487
+ rejectReviewRequest(error.message ?: "Review flow failed")
1958
2488
  }
1959
2489
  }
1960
2490
 
@@ -1985,6 +2515,8 @@ class CraftBridge(
1985
2515
  }
1986
2516
  } catch (e: CameraAccessException) {
1987
2517
  false
2518
+ } catch (e: SecurityException) {
2519
+ false
1988
2520
  }
1989
2521
  }
1990
2522
 
@@ -1998,6 +2530,8 @@ class CraftBridge(
1998
2530
 
1999
2531
  @JavascriptInterface
2000
2532
  fun openURL(url: String): Boolean {
2533
+ if (CraftNative.openURL(activity, url)) return true
2534
+
2001
2535
  return try {
2002
2536
  val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
2003
2537
  activity.startActivity(intent)
@@ -2011,6 +2545,8 @@ class CraftBridge(
2011
2545
 
2012
2546
  @JavascriptInterface
2013
2547
  fun vibrate(patternJson: String) {
2548
+ if (CraftNative.vibrate(activity, patternJson)) return
2549
+
2014
2550
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2015
2551
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
2016
2552
  vibratorManager.defaultVibrator
@@ -2039,11 +2575,21 @@ class CraftBridge(
2039
2575
 
2040
2576
  @JavascriptInterface
2041
2577
  fun getAppState(): String {
2578
+ // Null is "Zig declined". Otherwise this is the state Zig's observer
2579
+ // has been maintaining, and currentAppState below is the one this
2580
+ // file's observer maintains — whichever of the two is running.
2581
+ CraftNative.getAppState()?.let { return it }
2582
+
2042
2583
  return currentAppState
2043
2584
  }
2044
2585
 
2045
2586
  @JavascriptInterface
2046
2587
  fun startAppStateMonitoring() {
2588
+ // The observer object is CraftNative's, and so is the state it
2589
+ // maintains — which is why getAppState below has to reach the same
2590
+ // way. All three move together or none of them can.
2591
+ if (CraftNative.startAppStateMonitoring(activity)) return
2592
+
2047
2593
  activity.runOnUiThread {
2048
2594
  ProcessLifecycleOwner.get().lifecycle.addObserver(appStateObserver)
2049
2595
  }
@@ -2051,6 +2597,8 @@ class CraftBridge(
2051
2597
 
2052
2598
  @JavascriptInterface
2053
2599
  fun stopAppStateMonitoring() {
2600
+ if (CraftNative.stopAppStateMonitoring(activity)) return
2601
+
2054
2602
  activity.runOnUiThread {
2055
2603
  ProcessLifecycleOwner.get().lifecycle.removeObserver(appStateObserver)
2056
2604
  }
@@ -2066,12 +2614,9 @@ class CraftBridge(
2066
2614
 
2067
2615
  if (newState != currentAppState) {
2068
2616
  currentAppState = newState
2069
- activity.runOnUiThread {
2070
- webView.evaluateJavascript(
2071
- "window._craftAppStateCallback && window._craftAppStateCallback('$currentAppState')",
2072
- null
2073
- )
2074
- }
2617
+ evaluateJavascriptUnlessClosed(
2618
+ "window._craftAppStateCallback && window._craftAppStateCallback(${jsQuote(currentAppState)})"
2619
+ )
2075
2620
  sendEvent("craftAppStateChange", mapOf("state" to currentAppState))
2076
2621
  }
2077
2622
  }
@@ -2080,83 +2625,99 @@ class CraftBridge(
2080
2625
 
2081
2626
  @JavascriptInterface
2082
2627
  fun getContacts() {
2628
+ // Zig answers through the reply channel, so returning here is what
2629
+ // stops the promise being settled twice.
2630
+ if (CraftNative.getContacts(activity)) return
2631
+
2083
2632
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
2084
2633
  != PackageManager.PERMISSION_GRANTED) {
2085
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_CONTACTS)
2086
- activity.runOnUiThread {
2087
- webView.evaluateJavascript("window._craftContactsReject && window._craftContactsReject('Permission denied')", null)
2088
- }
2634
+ evaluatePromiseJavascript("window._craftContactsReject && window._craftContactsReject('Permission denied')")
2635
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_CONTACTS)
2636
+ return
2637
+ }
2638
+
2639
+ val contacts = try {
2640
+ loadContacts()
2641
+ } catch (error: Exception) {
2642
+ evaluatePromiseJavascript(
2643
+ "window._craftContactsReject && window._craftContactsReject(${jsQuote(error.message ?: "Contacts could not be read")})"
2644
+ )
2089
2645
  return
2090
2646
  }
2091
2647
 
2092
- val contacts = JSONArray()
2648
+ evaluatePromiseJavascript("window._craftContactsResolve && window._craftContactsResolve($contacts)")
2649
+ }
2650
+
2651
+ private fun loadContacts(): JSONArray {
2652
+ val contactRows = mutableListOf<Pair<String?, String?>>()
2093
2653
  val cursor: Cursor? = activity.contentResolver.query(
2094
2654
  ContactsContract.Contacts.CONTENT_URI,
2095
- null, null, null, ContactsContract.Contacts.DISPLAY_NAME + " ASC"
2655
+ arrayOf(
2656
+ ContactsContract.Contacts._ID,
2657
+ ContactsContract.Contacts.DISPLAY_NAME
2658
+ ),
2659
+ null, null, ContactsContract.Contacts.DISPLAY_NAME + " ASC"
2096
2660
  )
2097
2661
 
2098
2662
  cursor?.use {
2663
+ val idIndex = it.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
2664
+ val nameIndex = it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME)
2099
2665
  while (it.moveToNext()) {
2100
- val id = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
2101
- val name = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
2102
-
2103
- val contact = JSONObject().apply {
2104
- put("id", id)
2105
- put("displayName", name ?: "")
2106
- put("phoneNumbers", getContactPhones(id))
2107
- put("emailAddresses", getContactEmails(id))
2108
- }
2109
- contacts.put(contact)
2666
+ contactRows.add(it.getString(idIndex) to it.getString(nameIndex))
2110
2667
  }
2111
2668
  }
2112
2669
 
2113
- activity.runOnUiThread {
2114
- webView.evaluateJavascript("window._craftContactsResolve && window._craftContactsResolve($contacts)", null)
2115
- }
2116
- }
2117
-
2118
- private fun getContactPhones(contactId: String): JSONArray {
2119
- val phones = JSONArray()
2120
- val cursor = activity.contentResolver.query(
2670
+ val phoneNumbers = getContactValues(
2121
2671
  ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
2122
- null,
2123
- ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?",
2124
- arrayOf(contactId), null
2672
+ ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
2673
+ ContactsContract.CommonDataKinds.Phone.NUMBER
2125
2674
  )
2126
- cursor?.use {
2127
- while (it.moveToNext()) {
2128
- val phone = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
2129
- phones.put(phone)
2675
+ val emailAddresses = getContactValues(
2676
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
2677
+ ContactsContract.CommonDataKinds.Email.CONTACT_ID,
2678
+ ContactsContract.CommonDataKinds.Email.ADDRESS
2679
+ )
2680
+
2681
+ return JSONArray().apply {
2682
+ contactRows.forEach { (id, name) ->
2683
+ put(JSONObject().apply {
2684
+ put("id", id)
2685
+ put("displayName", name ?: "")
2686
+ put("phoneNumbers", id?.let { phoneNumbers[it] } ?: JSONArray())
2687
+ put("emailAddresses", id?.let { emailAddresses[it] } ?: JSONArray())
2688
+ })
2130
2689
  }
2131
2690
  }
2132
- return phones
2133
2691
  }
2134
2692
 
2135
- private fun getContactEmails(contactId: String): JSONArray {
2136
- val emails = JSONArray()
2693
+ private fun getContactValues(contentUri: Uri, contactIdColumn: String, valueColumn: String): Map<String, JSONArray> {
2694
+ val values = mutableMapOf<String, JSONArray>()
2137
2695
  val cursor = activity.contentResolver.query(
2138
- ContactsContract.CommonDataKinds.Email.CONTENT_URI,
2139
- null,
2140
- ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?",
2141
- arrayOf(contactId), null
2696
+ contentUri,
2697
+ arrayOf(contactIdColumn, valueColumn),
2698
+ null, null, null
2142
2699
  )
2143
2700
  cursor?.use {
2701
+ val contactIdIndex = it.getColumnIndexOrThrow(contactIdColumn)
2702
+ val valueIndex = it.getColumnIndexOrThrow(valueColumn)
2144
2703
  while (it.moveToNext()) {
2145
- val email = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
2146
- emails.put(email)
2704
+ val contactId = it.getString(contactIdIndex) ?: continue
2705
+ values.getOrPut(contactId) { JSONArray() }.put(it.getString(valueIndex))
2147
2706
  }
2148
2707
  }
2149
- return emails
2708
+ return values
2150
2709
  }
2151
2710
 
2152
2711
  @JavascriptInterface
2153
2712
  fun addContact(contactJson: String) {
2713
+ // Zig answers through the reply channel, so returning here is what
2714
+ // stops the promise being settled twice.
2715
+ if (CraftNative.addContact(activity, contactJson)) return
2716
+
2154
2717
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CONTACTS)
2155
2718
  != PackageManager.PERMISSION_GRANTED) {
2156
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CONTACTS), REQUEST_CONTACTS)
2157
- activity.runOnUiThread {
2158
- webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('Permission denied')", null)
2159
- }
2719
+ evaluatePromiseJavascript("window._craftAddContactReject && window._craftAddContactReject('Permission denied')")
2720
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.WRITE_CONTACTS), REQUEST_CONTACTS)
2160
2721
  return
2161
2722
  }
2162
2723
 
@@ -2204,13 +2765,13 @@ class CraftBridge(
2204
2765
  val results = activity.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
2205
2766
  val contactId = ContentUris.parseId(results[0].uri!!)
2206
2767
 
2207
- activity.runOnUiThread {
2208
- webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve('$contactId')", null)
2209
- }
2768
+ evaluatePromiseJavascript(
2769
+ "window._craftAddContactResolve && window._craftAddContactResolve(${jsQuote(contactId)})"
2770
+ )
2210
2771
  } catch (e: Exception) {
2211
- activity.runOnUiThread {
2212
- webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('${e.message}')", null)
2213
- }
2772
+ evaluatePromiseJavascript(
2773
+ "window._craftAddContactReject && window._craftAddContactReject(${jsQuote(e.message ?: "Contact could not be added")})"
2774
+ )
2214
2775
  }
2215
2776
  }
2216
2777
 
@@ -2218,65 +2779,73 @@ class CraftBridge(
2218
2779
 
2219
2780
  @JavascriptInterface
2220
2781
  fun getCalendarEvents(startDateMs: Long, endDateMs: Long) {
2782
+ // Zig answers through the reply channel, so returning here is what
2783
+ // stops the promise being settled twice.
2784
+ if (CraftNative.getCalendarEvents(activity, startDateMs, endDateMs)) return
2785
+
2221
2786
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CALENDAR)
2222
2787
  != PackageManager.PERMISSION_GRANTED) {
2223
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR), REQUEST_CALENDAR)
2224
- activity.runOnUiThread {
2225
- webView.evaluateJavascript("window._craftCalendarReject && window._craftCalendarReject('Permission denied')", null)
2226
- }
2788
+ evaluatePromiseJavascript("window._craftCalendarReject && window._craftCalendarReject('Permission denied')")
2789
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.READ_CALENDAR), REQUEST_CALENDAR)
2227
2790
  return
2228
2791
  }
2229
2792
 
2230
- val events = JSONArray()
2231
- val startTime = if (startDateMs > 0) startDateMs else System.currentTimeMillis()
2232
- val endTime = if (endDateMs > 0) endDateMs else startTime + (30L * 24 * 60 * 60 * 1000) // 30 days
2793
+ try {
2794
+ val events = JSONArray()
2795
+ val startTime = if (startDateMs > 0) startDateMs else System.currentTimeMillis()
2796
+ val endTime = if (endDateMs > 0) endDateMs else startTime + (30L * 24 * 60 * 60 * 1000) // 30 days
2233
2797
 
2234
- val projection = arrayOf(
2235
- CalendarContract.Events._ID,
2236
- CalendarContract.Events.TITLE,
2237
- CalendarContract.Events.DESCRIPTION,
2238
- CalendarContract.Events.DTSTART,
2239
- CalendarContract.Events.DTEND,
2240
- CalendarContract.Events.EVENT_LOCATION,
2241
- CalendarContract.Events.ALL_DAY
2242
- )
2798
+ val projection = arrayOf(
2799
+ CalendarContract.Events._ID,
2800
+ CalendarContract.Events.TITLE,
2801
+ CalendarContract.Events.DESCRIPTION,
2802
+ CalendarContract.Events.DTSTART,
2803
+ CalendarContract.Events.DTEND,
2804
+ CalendarContract.Events.EVENT_LOCATION,
2805
+ CalendarContract.Events.ALL_DAY
2806
+ )
2243
2807
 
2244
- val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)"
2245
- val selectionArgs = arrayOf(startTime.toString(), endTime.toString())
2808
+ val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)"
2809
+ val selectionArgs = arrayOf(startTime.toString(), endTime.toString())
2246
2810
 
2247
- val cursor = activity.contentResolver.query(
2248
- CalendarContract.Events.CONTENT_URI,
2249
- projection, selection, selectionArgs, CalendarContract.Events.DTSTART + " ASC"
2250
- )
2811
+ val cursor = activity.contentResolver.query(
2812
+ CalendarContract.Events.CONTENT_URI,
2813
+ projection, selection, selectionArgs, CalendarContract.Events.DTSTART + " ASC"
2814
+ )
2251
2815
 
2252
- cursor?.use {
2253
- while (it.moveToNext()) {
2254
- val event = JSONObject().apply {
2255
- put("id", it.getString(0))
2256
- put("title", it.getString(1) ?: "")
2257
- put("notes", it.getString(2) ?: "")
2258
- put("startDate", it.getLong(3))
2259
- put("endDate", it.getLong(4))
2260
- put("location", it.getString(5) ?: "")
2261
- put("isAllDay", it.getInt(6) == 1)
2816
+ cursor?.use {
2817
+ while (it.moveToNext()) {
2818
+ val event = JSONObject().apply {
2819
+ put("id", it.getString(0))
2820
+ put("title", it.getString(1) ?: "")
2821
+ put("notes", it.getString(2) ?: "")
2822
+ put("startDate", it.getLong(3))
2823
+ put("endDate", it.getLong(4))
2824
+ put("location", it.getString(5) ?: "")
2825
+ put("isAllDay", it.getInt(6) == 1)
2826
+ }
2827
+ events.put(event)
2262
2828
  }
2263
- events.put(event)
2264
2829
  }
2265
- }
2266
2830
 
2267
- activity.runOnUiThread {
2268
- webView.evaluateJavascript("window._craftCalendarResolve && window._craftCalendarResolve($events)", null)
2831
+ evaluatePromiseJavascript("window._craftCalendarResolve && window._craftCalendarResolve($events)")
2832
+ } catch (e: Exception) {
2833
+ evaluatePromiseJavascript(
2834
+ "window._craftCalendarReject && window._craftCalendarReject(${jsQuote(e.message ?: "Calendar events could not be read")})"
2835
+ )
2269
2836
  }
2270
2837
  }
2271
2838
 
2272
2839
  @JavascriptInterface
2273
2840
  fun createCalendarEvent(eventJson: String) {
2841
+ // Zig answers through the reply channel, so returning here is what
2842
+ // stops the promise being settled twice.
2843
+ if (CraftNative.createCalendarEvent(activity, eventJson)) return
2844
+
2274
2845
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CALENDAR)
2275
2846
  != PackageManager.PERMISSION_GRANTED) {
2276
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CALENDAR), REQUEST_CALENDAR)
2277
- activity.runOnUiThread {
2278
- webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('Permission denied')", null)
2279
- }
2847
+ evaluatePromiseJavascript("window._craftCreateEventReject && window._craftCreateEventReject('Permission denied')")
2848
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.WRITE_CALENDAR), REQUEST_CALENDAR)
2280
2849
  return
2281
2850
  }
2282
2851
 
@@ -2296,28 +2865,30 @@ class CraftBridge(
2296
2865
  val uri = activity.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
2297
2866
  val eventId = uri?.lastPathSegment ?: ""
2298
2867
 
2299
- activity.runOnUiThread {
2300
- webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve('$eventId')", null)
2301
- }
2868
+ evaluatePromiseJavascript(
2869
+ "window._craftCreateEventResolve && window._craftCreateEventResolve(${jsQuote(eventId)})"
2870
+ )
2302
2871
  } catch (e: Exception) {
2303
- activity.runOnUiThread {
2304
- webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('${e.message}')", null)
2305
- }
2872
+ evaluatePromiseJavascript(
2873
+ "window._craftCreateEventReject && window._craftCreateEventReject(${jsQuote(e.message ?: "Calendar event could not be created")})"
2874
+ )
2306
2875
  }
2307
2876
  }
2308
2877
 
2309
2878
  @JavascriptInterface
2310
2879
  fun deleteCalendarEvent(eventId: String) {
2880
+ // Zig answers through the reply channel, so returning here is what
2881
+ // stops the promise being settled twice.
2882
+ if (CraftNative.deleteCalendarEvent(activity, eventId)) return
2883
+
2311
2884
  try {
2312
2885
  val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId.toLong())
2313
2886
  activity.contentResolver.delete(uri, null, null)
2314
- activity.runOnUiThread {
2315
- webView.evaluateJavascript("window._craftDeleteEventResolve && window._craftDeleteEventResolve(true)", null)
2316
- }
2887
+ evaluatePromiseJavascript("window._craftDeleteEventResolve && window._craftDeleteEventResolve(true)")
2317
2888
  } catch (e: Exception) {
2318
- activity.runOnUiThread {
2319
- webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null)
2320
- }
2889
+ evaluatePromiseJavascript(
2890
+ "window._craftDeleteEventReject && window._craftDeleteEventReject(${jsQuote(e.message ?: "Calendar event could not be deleted")})"
2891
+ )
2321
2892
  }
2322
2893
  }
2323
2894
 
@@ -2325,6 +2896,11 @@ class CraftBridge(
2325
2896
 
2326
2897
  @JavascriptInterface
2327
2898
  fun scheduleNotification(notificationJson: String) {
2899
+ // Zig answers through the reply channel, so returning here is what
2900
+ // stops the promise being settled twice. It declines a delayed
2901
+ // notification before doing any of the work below.
2902
+ if (CraftNative.scheduleNotification(activity, notificationJson)) return
2903
+
2328
2904
  try {
2329
2905
  val notif = JSONObject(notificationJson)
2330
2906
  val title = notif.optString("title", "")
@@ -2352,13 +2928,13 @@ class CraftBridge(
2352
2928
  notificationManager.notify(id.hashCode(), builder.build())
2353
2929
  }
2354
2930
 
2355
- activity.runOnUiThread {
2356
- webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve('$id')", null)
2357
- }
2931
+ evaluatePromiseJavascript(
2932
+ "window._craftNotifResolve && window._craftNotifResolve(${jsQuote(id)})"
2933
+ )
2358
2934
  } catch (e: Exception) {
2359
- activity.runOnUiThread {
2360
- webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject('${e.message}')", null)
2361
- }
2935
+ evaluatePromiseJavascript(
2936
+ "window._craftNotifReject && window._craftNotifReject(${jsQuote(e.message ?: "Notification could not be scheduled")})"
2937
+ )
2362
2938
  }
2363
2939
  }
2364
2940
 
@@ -2377,12 +2953,16 @@ class CraftBridge(
2377
2953
 
2378
2954
  @JavascriptInterface
2379
2955
  fun cancelNotification(id: String) {
2956
+ if (CraftNative.cancelNotification(activity, id)) return
2957
+
2380
2958
  val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2381
2959
  notificationManager.cancel(id.hashCode())
2382
2960
  }
2383
2961
 
2384
2962
  @JavascriptInterface
2385
2963
  fun cancelAllNotifications() {
2964
+ if (CraftNative.cancelAllNotifications(activity)) return
2965
+
2386
2966
  val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2387
2967
  notificationManager.cancelAll()
2388
2968
  }
@@ -2391,6 +2971,20 @@ class CraftBridge(
2391
2971
 
2392
2972
  @JavascriptInterface
2393
2973
  fun getProducts(productIdsJson: String) {
2974
+ if (CraftNative.getProducts(activity, productIdsJson)) return
2975
+
2976
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
2977
+
2978
+ fun resolveProductRequest(products: JSONArray) {
2979
+ if (!settled.compareAndSet(false, true) || closed) return
2980
+ evaluatePromiseJavascript("window._craftProductsResolve && window._craftProductsResolve($products)")
2981
+ }
2982
+
2983
+ fun rejectProductRequest(message: String) {
2984
+ if (!settled.compareAndSet(false, true) || closed) return
2985
+ rejectProducts(message)
2986
+ }
2987
+
2394
2988
  try {
2395
2989
  val productIds = JSONArray(productIdsJson)
2396
2990
  val productList = mutableListOf<String>()
@@ -2398,12 +2992,14 @@ class CraftBridge(
2398
2992
  productList.add(productIds.getString(i))
2399
2993
  }
2400
2994
 
2401
- billingClient = BillingClient.newBuilder(activity)
2995
+ runCatching { productBillingClient?.endConnection() }
2996
+ val client = BillingClient.newBuilder(activity)
2402
2997
  .setListener { _, _ -> }
2403
2998
  .enablePendingPurchases()
2404
2999
  .build()
3000
+ productBillingClient = client
2405
3001
 
2406
- billingClient?.startConnection(object : BillingClientStateListener {
3002
+ client.startConnection(object : BillingClientStateListener {
2407
3003
  override fun onBillingSetupFinished(billingResult: BillingResult) {
2408
3004
  if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
2409
3005
  val params = QueryProductDetailsParams.newBuilder()
@@ -2415,96 +3011,166 @@ class CraftBridge(
2415
3011
  })
2416
3012
  .build()
2417
3013
 
2418
- billingClient?.queryProductDetailsAsync(params) { result, productDetailsList ->
2419
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
2420
- val products = JSONArray()
2421
- productDetailsList.forEach { details ->
2422
- products.put(JSONObject().apply {
2423
- put("id", details.productId)
2424
- put("displayName", details.name)
2425
- put("description", details.description)
2426
- put("price", details.oneTimePurchaseOfferDetails?.formattedPrice ?: "")
2427
- })
2428
- }
2429
- activity.runOnUiThread {
2430
- webView.evaluateJavascript("window._craftProductsResolve && window._craftProductsResolve($products)", null)
2431
- }
2432
- } else {
2433
- activity.runOnUiThread {
2434
- webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('Query failed')", null)
3014
+ try {
3015
+ client.queryProductDetailsAsync(params) { result, productDetailsList ->
3016
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
3017
+ val products = JSONArray()
3018
+ productDetailsList.forEach { details ->
3019
+ products.put(JSONObject().apply {
3020
+ put("id", details.productId)
3021
+ put("title", details.name)
3022
+ put("displayName", details.name)
3023
+ put("description", details.description)
3024
+ put("price", details.oneTimePurchaseOfferDetails?.formattedPrice ?: "")
3025
+ })
3026
+ }
3027
+ resolveProductRequest(products)
3028
+ } else {
3029
+ rejectProductRequest("Product query failed: ${result.debugMessage}")
2435
3030
  }
2436
3031
  }
3032
+ } catch (error: Exception) {
3033
+ rejectProductRequest(error.message ?: "Product query failed")
2437
3034
  }
3035
+ } else {
3036
+ rejectProductRequest("Billing setup failed: ${billingResult.debugMessage}")
2438
3037
  }
2439
3038
  }
2440
3039
 
2441
3040
  override fun onBillingServiceDisconnected() {
2442
- activity.runOnUiThread {
2443
- webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('Billing disconnected')", null)
2444
- }
3041
+ rejectProductRequest("Billing disconnected")
2445
3042
  }
2446
3043
  })
2447
3044
  } catch (e: Exception) {
2448
- activity.runOnUiThread {
2449
- webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('${e.message}')", null)
2450
- }
3045
+ rejectProductRequest(e.message ?: "Product query failed")
2451
3046
  }
2452
3047
  }
2453
3048
 
3049
+ private fun rejectProducts(message: String) {
3050
+ evaluatePromiseJavascript(
3051
+ "window._craftProductsReject && window._craftProductsReject(${jsQuote(message)})"
3052
+ )
3053
+ }
3054
+
2454
3055
  @JavascriptInterface
2455
3056
  fun purchase(productId: String) {
2456
3057
  // Simplified - real implementation would query product details first
2457
- activity.runOnUiThread {
2458
- webView.evaluateJavascript("window._craftPurchaseReject && window._craftPurchaseReject('Purchase flow not fully implemented')", null)
2459
- }
3058
+ evaluatePromiseJavascript(
3059
+ "window._craftPurchaseReject && window._craftPurchaseReject('Purchase flow not fully implemented')"
3060
+ )
2460
3061
  }
2461
3062
 
2462
3063
  @JavascriptInterface
2463
3064
  fun restorePurchases() {
2464
- billingClient?.queryPurchasesAsync(
2465
- QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
2466
- ) { result, purchases ->
2467
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
2468
- val restored = JSONArray()
2469
- purchases.forEach { purchase ->
2470
- restored.put(JSONObject().apply {
2471
- put("productId", purchase.products.firstOrNull())
2472
- put("orderId", purchase.orderId)
2473
- })
2474
- }
2475
- activity.runOnUiThread {
2476
- webView.evaluateJavascript("window._craftRestoreResolve && window._craftRestoreResolve($restored)", null)
2477
- }
2478
- } else {
2479
- activity.runOnUiThread {
2480
- webView.evaluateJavascript("window._craftRestoreReject && window._craftRestoreReject('Restore failed')", null)
2481
- }
2482
- }
2483
- }
2484
- }
3065
+ if (CraftNative.restorePurchases(activity)) return
2485
3066
 
2486
- // ==================== Keep Awake ====================
3067
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
2487
3068
 
2488
- @JavascriptInterface
2489
- fun setKeepAwake(enabled: Boolean): Boolean {
2490
- activity.runOnUiThread {
2491
- if (enabled) {
2492
- activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
2493
- } else {
2494
- activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
2495
- }
2496
- isKeepingAwake = enabled
3069
+ fun resolveRestoreRequest(restored: JSONArray) {
3070
+ if (!settled.compareAndSet(false, true) || closed) return
3071
+ evaluatePromiseJavascript("window._craftRestoreResolve && window._craftRestoreResolve($restored)")
2497
3072
  }
2498
- return true
2499
- }
2500
3073
 
2501
- // ==================== Orientation Lock ====================
3074
+ fun rejectRestoreRequest(message: String) {
3075
+ if (!settled.compareAndSet(false, true) || closed) return
3076
+ rejectRestore(message)
3077
+ }
2502
3078
 
2503
- @JavascriptInterface
2504
- fun lockOrientation(orientation: String): Boolean {
2505
- activity.runOnUiThread {
2506
- activity.requestedOrientation = when (orientation) {
2507
- "portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
3079
+ val connectedClient = restoreBillingClient
3080
+ if (connectedClient != null && connectedClient.isReady) {
3081
+ queryRestoredPurchases(connectedClient, ::resolveRestoreRequest, ::rejectRestoreRequest)
3082
+ return
3083
+ }
3084
+
3085
+ try {
3086
+ val client = connectedClient ?: BillingClient.newBuilder(activity)
3087
+ .setListener { _, _ -> }
3088
+ .enablePendingPurchases()
3089
+ .build()
3090
+ .also { restoreBillingClient = it }
3091
+
3092
+ client.startConnection(object : BillingClientStateListener {
3093
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
3094
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
3095
+ queryRestoredPurchases(client, ::resolveRestoreRequest, ::rejectRestoreRequest)
3096
+ } else {
3097
+ rejectRestoreRequest("Billing setup failed: ${billingResult.debugMessage}")
3098
+ }
3099
+ }
3100
+
3101
+ override fun onBillingServiceDisconnected() {
3102
+ rejectRestoreRequest("Billing disconnected")
3103
+ }
3104
+ })
3105
+ } catch (error: Exception) {
3106
+ rejectRestoreRequest(error.message ?: "Billing connection failed")
3107
+ }
3108
+ }
3109
+
3110
+ private fun queryRestoredPurchases(
3111
+ client: BillingClient,
3112
+ resolve: (JSONArray) -> Unit,
3113
+ reject: (String) -> Unit
3114
+ ) {
3115
+ try {
3116
+ client.queryPurchasesAsync(
3117
+ QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
3118
+ ) { result, purchases ->
3119
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
3120
+ val restored = JSONArray()
3121
+ purchases.forEach { purchase ->
3122
+ restored.put(JSONObject().apply {
3123
+ put("productId", purchase.products.firstOrNull())
3124
+ put("orderId", purchase.orderId)
3125
+ })
3126
+ }
3127
+ resolve(restored)
3128
+ } else {
3129
+ reject("Restore failed: ${result.debugMessage}")
3130
+ }
3131
+ }
3132
+ } catch (error: Exception) {
3133
+ reject(error.message ?: "Restore failed")
3134
+ }
3135
+ }
3136
+
3137
+ private fun rejectRestore(message: String) {
3138
+ evaluatePromiseJavascript(
3139
+ "window._craftRestoreReject && window._craftRestoreReject(${jsQuote(message)})"
3140
+ )
3141
+ }
3142
+
3143
+ // ==================== Keep Awake ====================
3144
+
3145
+ @JavascriptInterface
3146
+ fun setKeepAwake(enabled: Boolean): Boolean {
3147
+ // Zig queues the same window call on the same looper. isKeepingAwake
3148
+ // below is written here and read nowhere, so the Kotlin body not
3149
+ // running leaves nothing stale — unlike the flashlight pair.
3150
+ if (CraftNative.setKeepAwake(activity, enabled)) return true
3151
+
3152
+ activity.runOnUiThread {
3153
+ if (enabled) {
3154
+ activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
3155
+ } else {
3156
+ activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
3157
+ }
3158
+ isKeepingAwake = enabled
3159
+ }
3160
+ return true
3161
+ }
3162
+
3163
+ // ==================== Orientation Lock ====================
3164
+
3165
+ @JavascriptInterface
3166
+ fun lockOrientation(orientation: String): Boolean {
3167
+ // Zig queues the same work on the same looper, through a Runnable
3168
+ // this file's holder owns — see CraftNative.runOnMain.
3169
+ if (CraftNative.lockOrientation(activity, orientation)) return true
3170
+
3171
+ activity.runOnUiThread {
3172
+ activity.requestedOrientation = when (orientation) {
3173
+ "portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
2508
3174
  "landscape" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
2509
3175
  "landscapeLeft" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
2510
3176
  "landscapeRight" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
@@ -2516,6 +3182,8 @@ class CraftBridge(
2516
3182
 
2517
3183
  @JavascriptInterface
2518
3184
  fun unlockOrientation(): Boolean {
3185
+ if (CraftNative.unlockOrientation(activity)) return true
3186
+
2519
3187
  activity.runOnUiThread {
2520
3188
  activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
2521
3189
  }
@@ -2539,29 +3207,51 @@ class CraftBridge(
2539
3207
  @JavascriptInterface
2540
3208
  fun scanQRCode() {
2541
3209
  // Uses ML Kit barcode scanning - requires camera permission
2542
- activity.runOnUiThread {
2543
- webView.evaluateJavascript(
2544
- "window._craftQRReject && window._craftQRReject('QR scanning requires camera integration - use native camera intent')",
2545
- null
2546
- )
2547
- }
3210
+ evaluatePromiseJavascript(
3211
+ "window._craftQRReject && window._craftQRReject('QR scanning requires camera integration - use native camera intent')"
3212
+ )
2548
3213
  }
2549
3214
 
2550
3215
  // ==================== File Picker ====================
2551
3216
 
2552
3217
  @JavascriptInterface
2553
3218
  fun pickFile(typesJson: String) {
2554
- val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
2555
- addCategory(Intent.CATEGORY_OPENABLE)
2556
- type = "*/*"
3219
+ val requestedTypes = try {
3220
+ val values = JSONArray(typesJson)
3221
+ (0 until values.length()).map { index -> values.getString(index) }.also { types ->
3222
+ require(types.all { MIME_TYPE.matches(it) }) { "Invalid file-picker MIME type" }
3223
+ }
3224
+ } catch (error: Exception) {
3225
+ rejectFilePicker(error.message ?: "File-picker types are invalid")
3226
+ return
3227
+ }
3228
+ // The native launcher has no MIME-type argument. It remains the fast
3229
+ // path for an unrestricted picker; filtered requests stay here so the
3230
+ // contract the page asked for reaches Android's document provider.
3231
+ if (requestedTypes.isEmpty() && CraftNative.pickFile(activity)) return
3232
+
3233
+ try {
3234
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
3235
+ addCategory(Intent.CATEGORY_OPENABLE)
3236
+ type = if (requestedTypes.size == 1) requestedTypes[0] else "*/*"
3237
+ if (requestedTypes.size > 1) {
3238
+ putExtra(Intent.EXTRA_MIME_TYPES, requestedTypes.toTypedArray())
3239
+ }
3240
+ }
3241
+ activity.startActivityForResult(intent, REQUEST_FILE_PICKER)
3242
+ } catch (error: Exception) {
3243
+ rejectFilePicker(error.message ?: "File picker could not be opened")
2557
3244
  }
2558
- activity.startActivityForResult(intent, REQUEST_FILE_PICKER)
2559
3245
  }
2560
3246
 
2561
3247
  // ==================== File Download ====================
2562
3248
 
2563
3249
  @JavascriptInterface
2564
3250
  fun downloadFile(url: String, filename: String) {
3251
+ // Zig answers through the reply channel, so returning here is what
3252
+ // stops the promise being settled twice.
3253
+ if (CraftNative.downloadFile(activity, url, filename)) return
3254
+
2565
3255
  try {
2566
3256
  val request = DownloadManager.Request(Uri.parse(url)).apply {
2567
3257
  setTitle(filename)
@@ -2573,24 +3263,22 @@ class CraftBridge(
2573
3263
  val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
2574
3264
  val downloadId = downloadManager.enqueue(request)
2575
3265
 
2576
- activity.runOnUiThread {
2577
- webView.evaluateJavascript(
2578
- "window._craftDownloadResolve && window._craftDownloadResolve('$downloadId')",
2579
- null
2580
- )
2581
- }
3266
+ evaluatePromiseJavascript(
3267
+ "window._craftDownloadResolve && window._craftDownloadResolve(${jsQuote(downloadId)})"
3268
+ )
2582
3269
  } catch (e: Exception) {
2583
- activity.runOnUiThread {
2584
- webView.evaluateJavascript(
2585
- "window._craftDownloadReject && window._craftDownloadReject('${e.message}')",
2586
- null
2587
- )
2588
- }
3270
+ evaluatePromiseJavascript(
3271
+ "window._craftDownloadReject && window._craftDownloadReject(${jsQuote(e.message ?: "Download could not be started")})"
3272
+ )
2589
3273
  }
2590
3274
  }
2591
3275
 
2592
3276
  @JavascriptInterface
2593
3277
  fun saveFile(data: String, filename: String) {
3278
+ // Zig answers through the reply channel, so returning here is what
3279
+ // stops the promise being settled twice.
3280
+ if (CraftNative.saveFile(activity, data, filename)) return
3281
+
2594
3282
  try {
2595
3283
  val documentsDir = activity.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS)
2596
3284
  val file = File(documentsDir, filename)
@@ -2598,53 +3286,50 @@ class CraftBridge(
2598
3286
  if (data.startsWith("data:")) {
2599
3287
  // Base64 data URL
2600
3288
  val parts = data.split(",")
2601
- if (parts.size == 2) {
2602
- val bytes = Base64.decode(parts[1], Base64.DEFAULT)
2603
- file.writeBytes(bytes)
3289
+ if (parts.size != 2) {
3290
+ rejectSaveFile("Malformed data URL")
3291
+ return
2604
3292
  }
3293
+ val bytes = Base64.decode(parts[1], Base64.DEFAULT)
3294
+ file.writeBytes(bytes)
2605
3295
  } else {
2606
3296
  file.writeText(data)
2607
3297
  }
2608
3298
 
2609
- activity.runOnUiThread {
2610
- webView.evaluateJavascript(
2611
- "window._craftSaveResolve && window._craftSaveResolve('${file.absolutePath}')",
2612
- null
2613
- )
2614
- }
3299
+ evaluatePromiseJavascript(
3300
+ "window._craftSaveResolve && window._craftSaveResolve(${jsQuote(file.absolutePath)})"
3301
+ )
2615
3302
  } catch (e: Exception) {
2616
- activity.runOnUiThread {
2617
- webView.evaluateJavascript(
2618
- "window._craftSaveReject && window._craftSaveReject('${e.message}')",
2619
- null
2620
- )
2621
- }
3303
+ rejectSaveFile(e.message)
2622
3304
  }
2623
3305
  }
2624
3306
 
3307
+ private fun rejectSaveFile(message: String?) {
3308
+ evaluatePromiseJavascript(
3309
+ "window._craftSaveReject && window._craftSaveReject(${jsQuote(message ?: "File save failed")})"
3310
+ )
3311
+ }
3312
+
2625
3313
  // ==================== Google Sign In ====================
2626
3314
 
2627
3315
  @JavascriptInterface
2628
3316
  fun signInWithGoogle() {
2629
3317
  // Google Sign In requires configuration in build.gradle
2630
- activity.runOnUiThread {
2631
- webView.evaluateJavascript(
2632
- "window._craftGoogleReject && window._craftGoogleReject('Google Sign In requires app configuration')",
2633
- null
2634
- )
2635
- }
3318
+ evaluatePromiseJavascript(
3319
+ "window._craftGoogleReject && window._craftGoogleReject('Google Sign In requires app configuration')"
3320
+ )
2636
3321
  }
2637
3322
 
2638
3323
  // ==================== Audio Recording ====================
2639
3324
 
2640
3325
  @JavascriptInterface
2641
3326
  fun startAudioRecording() {
3327
+ if (CraftNative.startAudioRecording(activity)) return
3328
+
2642
3329
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
2643
3330
  != PackageManager.PERMISSION_GRANTED) {
2644
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO)
2645
- activity.runOnUiThread {
2646
- webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('Permission denied')", null)
2647
- }
3331
+ evaluatePromiseJavascript("window._craftAudioReject && window._craftAudioReject('Permission denied')")
3332
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO)
2648
3333
  return
2649
3334
  }
2650
3335
 
@@ -2668,18 +3353,18 @@ class CraftBridge(
2668
3353
  start()
2669
3354
  }
2670
3355
 
2671
- activity.runOnUiThread {
2672
- webView.evaluateJavascript("window._craftAudioResolve && window._craftAudioResolve(true)", null)
2673
- }
3356
+ evaluatePromiseJavascript("window._craftAudioResolve && window._craftAudioResolve(true)")
2674
3357
  } catch (e: Exception) {
2675
- activity.runOnUiThread {
2676
- webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('${e.message}')", null)
2677
- }
3358
+ evaluatePromiseJavascript(
3359
+ "window._craftAudioReject && window._craftAudioReject(${jsQuote(e.message ?: "Audio recording could not start")})"
3360
+ )
2678
3361
  }
2679
3362
  }
2680
3363
 
2681
3364
  @JavascriptInterface
2682
3365
  fun stopAudioRecording() {
3366
+ if (CraftNative.stopAudioRecording(activity)) return
3367
+
2683
3368
  try {
2684
3369
  mediaRecorder?.stop()
2685
3370
  mediaRecorder?.release()
@@ -2688,18 +3373,18 @@ class CraftBridge(
2688
3373
  audioFile?.let { file ->
2689
3374
  val bytes = file.readBytes()
2690
3375
  val base64 = "data:audio/m4a;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
2691
- activity.runOnUiThread {
2692
- webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve('$base64')", null)
2693
- }
3376
+ evaluatePromiseJavascript(
3377
+ "window._craftAudioStopResolve && window._craftAudioStopResolve(${jsQuote(base64)})"
3378
+ )
2694
3379
  } ?: run {
2695
- activity.runOnUiThread {
2696
- webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('No recording')", null)
2697
- }
3380
+ evaluatePromiseJavascript(
3381
+ "window._craftAudioStopReject && window._craftAudioStopReject('No recording')"
3382
+ )
2698
3383
  }
2699
3384
  } catch (e: Exception) {
2700
- activity.runOnUiThread {
2701
- webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('${e.message}')", null)
2702
- }
3385
+ evaluatePromiseJavascript(
3386
+ "window._craftAudioStopReject && window._craftAudioStopReject(${jsQuote(e.message ?: "Audio recording could not stop")})"
3387
+ )
2703
3388
  }
2704
3389
  }
2705
3390
 
@@ -2707,16 +3392,29 @@ class CraftBridge(
2707
3392
 
2708
3393
  @JavascriptInterface
2709
3394
  fun startVideoRecording() {
2710
- val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
2711
- putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1)
3395
+ if (CraftNative.startVideoRecording(activity)) return
3396
+
3397
+ try {
3398
+ val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
3399
+ putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1)
3400
+ }
3401
+ activity.startActivityForResult(intent, REQUEST_VIDEO)
3402
+ } catch (error: Exception) {
3403
+ evaluatePromiseJavascript(
3404
+ "window._craftVideoReject && window._craftVideoReject(${jsQuote(error.message ?: "Video capture could not be opened")})"
3405
+ )
2712
3406
  }
2713
- activity.startActivityForResult(intent, REQUEST_VIDEO)
2714
3407
  }
2715
3408
 
2716
3409
  // ==================== Motion Sensors ====================
2717
3410
 
2718
3411
  @JavascriptInterface
2719
3412
  fun startMotionUpdates(intervalMs: Int): Boolean {
3413
+ // The SensorEventListener object is CraftNative's, and so is the
3414
+ // last-value bookkeeping — which is why stopMotionUpdates has to reach
3415
+ // the same way, or its own fields stay null and nothing unregisters.
3416
+ if (CraftNative.startMotionUpdates(activity, intervalMs)) return true
3417
+
2720
3418
  sensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
2721
3419
  accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
2722
3420
  gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
@@ -2758,6 +3456,8 @@ class CraftBridge(
2758
3456
 
2759
3457
  @JavascriptInterface
2760
3458
  fun stopMotionUpdates() {
3459
+ if (CraftNative.stopMotionUpdates(activity)) return
3460
+
2761
3461
  sensorListener?.let { sensorManager?.unregisterListener(it) }
2762
3462
  sensorListener = null
2763
3463
  }
@@ -2771,24 +3471,29 @@ class CraftBridge(
2771
3471
  database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
2772
3472
  }
2773
3473
 
3474
+ // After the open, so Zig borrows this connection rather than
3475
+ // starting a second one against the same file. Zig answers through
3476
+ // the reply channel, so returning is what stops the promise being
3477
+ // settled twice.
3478
+ database?.let { if (CraftNative.dbExecute(it, sql, paramsJson)) return }
3479
+
2774
3480
  val params = JSONArray(paramsJson)
2775
3481
  val args = Array(params.length()) { params.getString(it) }
2776
3482
 
2777
- database?.execSQL(sql, args)
2778
-
2779
- activity.runOnUiThread {
2780
- webView.evaluateJavascript(
2781
- "window._craftDbExecResolve && window._craftDbExecResolve({rowsAffected: 1})",
2782
- null
2783
- )
3483
+ val rowsAffected = database!!.compileStatement(sql).use { statement ->
3484
+ args.forEachIndexed { index, value ->
3485
+ statement.bindString(index + 1, value)
3486
+ }
3487
+ statement.executeUpdateDelete()
2784
3488
  }
3489
+
3490
+ evaluatePromiseJavascript(
3491
+ "window._craftDbExecResolve && window._craftDbExecResolve({rowsAffected: $rowsAffected})"
3492
+ )
2785
3493
  } catch (e: Exception) {
2786
- activity.runOnUiThread {
2787
- webView.evaluateJavascript(
2788
- "window._craftDbExecReject && window._craftDbExecReject('${e.message}')",
2789
- null
2790
- )
2791
- }
3494
+ evaluatePromiseJavascript(
3495
+ "window._craftDbExecReject && window._craftDbExecReject(${jsQuote(e.message ?: "Database execution failed")})"
3496
+ )
2792
3497
  }
2793
3498
  }
2794
3499
 
@@ -2799,6 +3504,8 @@ class CraftBridge(
2799
3504
  database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
2800
3505
  }
2801
3506
 
3507
+ database?.let { if (CraftNative.dbQuery(it, sql, paramsJson)) return }
3508
+
2802
3509
  val params = JSONArray(paramsJson)
2803
3510
  val args = Array(params.length()) { params.getString(it) }
2804
3511
 
@@ -2816,19 +3523,11 @@ class CraftBridge(
2816
3523
  }
2817
3524
  }
2818
3525
 
2819
- activity.runOnUiThread {
2820
- webView.evaluateJavascript(
2821
- "window._craftDbQueryResolve && window._craftDbQueryResolve($results)",
2822
- null
2823
- )
2824
- }
3526
+ evaluatePromiseJavascript("window._craftDbQueryResolve && window._craftDbQueryResolve($results)")
2825
3527
  } catch (e: Exception) {
2826
- activity.runOnUiThread {
2827
- webView.evaluateJavascript(
2828
- "window._craftDbQueryReject && window._craftDbQueryReject('${e.message}')",
2829
- null
2830
- )
2831
- }
3528
+ evaluatePromiseJavascript(
3529
+ "window._craftDbQueryReject && window._craftDbQueryReject(${jsQuote(e.message ?: "Database query failed")})"
3530
+ )
2832
3531
  }
2833
3532
  }
2834
3533
 
@@ -2836,22 +3535,57 @@ class CraftBridge(
2836
3535
 
2837
3536
  @JavascriptInterface
2838
3537
  fun startBluetoothScan() {
3538
+ // The ScanCallback object is CraftNative's, and so are the scanner and
3539
+ // callback fields — which is why stopBluetoothScan has to reach the
3540
+ // same way, or its own fields stay null and stopScan is never called.
3541
+ if (CraftNative.startBluetoothScan(activity)) return
3542
+
2839
3543
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN)
2840
3544
  != PackageManager.PERMISSION_GRANTED) {
3545
+ rejectBluetoothScan("Bluetooth permission denied")
2841
3546
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2842
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.BLUETOOTH_SCAN), REQUEST_BLUETOOTH)
2843
- }
2844
- activity.runOnUiThread {
2845
- webView.evaluateJavascript("window._craftBleReject && window._craftBleReject('Permission denied')", null)
3547
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.BLUETOOTH_SCAN), REQUEST_BLUETOOTH)
2846
3548
  }
2847
3549
  return
2848
3550
  }
2849
3551
 
2850
- val bluetoothManager = activity.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
2851
- bluetoothAdapter = bluetoothManager.adapter
2852
- bluetoothScanner = bluetoothAdapter?.bluetoothLeScanner
3552
+ val bluetoothManager = activity.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
3553
+ if (bluetoothManager == null) {
3554
+ rejectBluetoothScan("Bluetooth is unavailable on this device")
3555
+ return
3556
+ }
3557
+ val adapter = try {
3558
+ bluetoothManager.adapter
3559
+ } catch (e: SecurityException) {
3560
+ rejectBluetoothScan("Bluetooth permission denied")
3561
+ return
3562
+ }
3563
+ if (adapter == null) {
3564
+ rejectBluetoothScan("Bluetooth is unavailable on this device")
3565
+ return
3566
+ }
3567
+ val enabled = try {
3568
+ adapter.isEnabled
3569
+ } catch (e: SecurityException) {
3570
+ rejectBluetoothScan("Bluetooth permission denied")
3571
+ return
3572
+ }
3573
+ if (!enabled) {
3574
+ rejectBluetoothScan("Bluetooth is switched off")
3575
+ return
3576
+ }
3577
+ val scanner = try {
3578
+ adapter.bluetoothLeScanner
3579
+ } catch (e: SecurityException) {
3580
+ rejectBluetoothScan("Bluetooth permission denied")
3581
+ return
3582
+ }
3583
+ if (scanner == null) {
3584
+ rejectBluetoothScan("Bluetooth LE scanning is unavailable")
3585
+ return
3586
+ }
2853
3587
 
2854
- bleScanCallback = object : ScanCallback() {
3588
+ val callback = object : ScanCallback() {
2855
3589
  override fun onScanResult(callbackType: Int, result: ScanResult) {
2856
3590
  sendEvent("craftBluetoothDevice", mapOf(
2857
3591
  "id" to result.device.address,
@@ -2861,14 +3595,31 @@ class CraftBridge(
2861
3595
  }
2862
3596
  }
2863
3597
 
2864
- bluetoothScanner?.startScan(bleScanCallback)
2865
- activity.runOnUiThread {
2866
- webView.evaluateJavascript("window._craftBleResolve && window._craftBleResolve(true)", null)
3598
+ try {
3599
+ scanner.startScan(callback)
3600
+ } catch (e: SecurityException) {
3601
+ rejectBluetoothScan("Bluetooth permission denied")
3602
+ return
3603
+ } catch (e: RuntimeException) {
3604
+ rejectBluetoothScan("Bluetooth scan could not start")
3605
+ return
2867
3606
  }
3607
+ bluetoothAdapter = adapter
3608
+ bluetoothScanner = scanner
3609
+ bleScanCallback = callback
3610
+ evaluatePromiseJavascript("window._craftBleResolve && window._craftBleResolve(true)")
3611
+ }
3612
+
3613
+ private fun rejectBluetoothScan(message: String) {
3614
+ evaluatePromiseJavascript(
3615
+ "window._craftBleReject && window._craftBleReject(${jsQuote(message)})"
3616
+ )
2868
3617
  }
2869
3618
 
2870
3619
  @JavascriptInterface
2871
3620
  fun stopBluetoothScan() {
3621
+ if (CraftNative.stopBluetoothScan(activity)) return
3622
+
2872
3623
  bleScanCallback?.let { bluetoothScanner?.stopScan(it) }
2873
3624
  bleScanCallback = null
2874
3625
  }
@@ -2879,15 +3630,13 @@ class CraftBridge(
2879
3630
  fun scanNFC() {
2880
3631
  val nfcAdapter = NfcAdapter.getDefaultAdapter(activity)
2881
3632
  if (nfcAdapter == null || !nfcAdapter.isEnabled) {
2882
- activity.runOnUiThread {
2883
- webView.evaluateJavascript("window._craftNfcReject && window._craftNfcReject('NFC not available')", null)
2884
- }
3633
+ evaluatePromiseJavascript("window._craftNfcReject && window._craftNfcReject('NFC not available')")
2885
3634
  return
2886
3635
  }
2887
3636
  // NFC requires foreground dispatch - simplified implementation
2888
- activity.runOnUiThread {
2889
- webView.evaluateJavascript("window._craftNfcReject && window._craftNfcReject('NFC requires activity integration')", null)
2890
- }
3637
+ evaluatePromiseJavascript(
3638
+ "window._craftNfcReject && window._craftNfcReject('NFC requires activity integration')"
3639
+ )
2891
3640
  }
2892
3641
 
2893
3642
  // ==================== Fitness ====================
@@ -2907,32 +3656,137 @@ class CraftBridge(
2907
3656
  healthConnect.saveWorkout(workoutJson)
2908
3657
  }
2909
3658
 
2910
- fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =
2911
- healthConnect.onActivityResult(requestCode, resultCode, data)
3659
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
3660
+ if (closed) return requestCode == REQUEST_CAMERA || requestCode == REQUEST_GALLERY ||
3661
+ requestCode == REQUEST_FILE_PICKER || requestCode == REQUEST_VIDEO ||
3662
+ requestCode == REQUEST_PICK_CONTACT
3663
+
3664
+ return when (requestCode) {
3665
+ REQUEST_CAMERA, REQUEST_GALLERY -> {
3666
+ handleImageResult(requestCode, resultCode, data)
3667
+ true
3668
+ }
3669
+ REQUEST_FILE_PICKER -> {
3670
+ handleFilePickerResult(resultCode, data)
3671
+ true
3672
+ }
3673
+ REQUEST_VIDEO -> {
3674
+ handleVideoResult(resultCode, data)
3675
+ true
3676
+ }
3677
+ REQUEST_PICK_CONTACT -> {
3678
+ handleContactPickerResult(resultCode, data)
3679
+ true
3680
+ }
3681
+ else -> healthConnect.onActivityResult(requestCode, resultCode, data)
3682
+ }
3683
+ }
2912
3684
 
2913
3685
  fun close() {
3686
+ closed = true
3687
+ currentPositionSequence.incrementAndGet()
3688
+ activity.runOnUiThread {
3689
+ runCatching {
3690
+ webView.evaluateJavascript(
3691
+ "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
3692
+ "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed')",
3693
+ null
3694
+ )
3695
+ }
3696
+ }
3697
+ CraftNative.close(activity)
2914
3698
  healthConnect.close()
3699
+
3700
+ runCatching { speechRecognizer?.cancel() }
3701
+ runCatching { speechRecognizer?.destroy() }
3702
+ speechRecognizer = null
3703
+ runCatching { biometricPrompt?.cancelAuthentication() }
3704
+ biometricPrompt = null
3705
+
3706
+ mediaRecorder?.let { recorder ->
3707
+ runCatching { recorder.stop() }
3708
+ runCatching { recorder.release() }
3709
+ }
3710
+ mediaRecorder = null
3711
+ audioFile?.delete()
3712
+ audioFile = null
3713
+
3714
+ locationCallbacks.values.forEach { callback ->
3715
+ runCatching { fusedLocationClient?.removeLocationUpdates(callback) }
3716
+ }
3717
+ locationCallbacks.clear()
3718
+ oneShotLocationCallbacks.forEach { callback ->
3719
+ runCatching { fusedLocationClient?.removeLocationUpdates(callback) }
3720
+ }
3721
+ oneShotLocationCallbacks.clear()
3722
+ oneShotLocationTimeouts.values.forEach { timeout ->
3723
+ oneShotLocationHandler.removeCallbacks(timeout)
3724
+ }
3725
+ oneShotLocationTimeouts.clear()
3726
+
3727
+ networkCallback?.let { callback ->
3728
+ val manager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
3729
+ runCatching { manager.unregisterNetworkCallback(callback) }
3730
+ }
3731
+ networkCallback = null
3732
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(appStateObserver)
3733
+
3734
+ sensorListener?.let { listener ->
3735
+ runCatching { sensorManager?.unregisterListener(listener) }
3736
+ }
3737
+ sensorListener = null
3738
+ sensorManager = null
3739
+
3740
+ bleScanCallback?.let { callback ->
3741
+ runCatching { bluetoothScanner?.stopScan(callback) }
3742
+ }
3743
+ bleScanCallback = null
3744
+ bluetoothScanner = null
3745
+ bluetoothAdapter = null
3746
+
3747
+ runCatching { productBillingClient?.endConnection() }
3748
+ productBillingClient = null
3749
+ runCatching { restoreBillingClient?.endConnection() }
3750
+ restoreBillingClient = null
3751
+ runCatching { database?.close() }
3752
+ database = null
3753
+
3754
+ if (isFlashlightOn) setFlashlight(false)
3755
+ activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
3756
+ isKeepingAwake = false
3757
+ pendingPermissionRequests.clear()
3758
+ pendingEvents.clear()
2915
3759
  }
2916
3760
 
2917
3761
  // ==================== Screen Capture ====================
2918
3762
 
2919
3763
  @JavascriptInterface
2920
3764
  fun takeScreenshot() {
3765
+ if (CraftNative.takeScreenshot(activity, webView)) return
3766
+
2921
3767
  activity.runOnUiThread {
3768
+ if (closed) return@runOnUiThread
2922
3769
  try {
2923
3770
  val view = webView.rootView
2924
- view.isDrawingCacheEnabled = true
2925
- view.buildDrawingCache()
2926
- val bitmap = Bitmap.createBitmap(view.drawingCache)
2927
- view.isDrawingCacheEnabled = false
3771
+ val bitmap = try {
3772
+ view.isDrawingCacheEnabled = true
3773
+ view.buildDrawingCache()
3774
+ Bitmap.createBitmap(view.drawingCache)
3775
+ } finally {
3776
+ view.isDrawingCacheEnabled = false
3777
+ }
2928
3778
 
2929
3779
  val outputStream = ByteArrayOutputStream()
2930
3780
  bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
2931
3781
  val base64 = "data:image/png;base64," + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
2932
3782
 
2933
- webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve('$base64')", null)
3783
+ evaluatePromiseJavascript(
3784
+ "window._craftScreenshotResolve && window._craftScreenshotResolve(${jsQuote(base64)})"
3785
+ )
2934
3786
  } catch (e: Exception) {
2935
- webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject('${e.message}')", null)
3787
+ evaluatePromiseJavascript(
3788
+ "window._craftScreenshotReject && window._craftScreenshotReject(${jsQuote(e.message ?: "Screenshot failed")})"
3789
+ )
2936
3790
  }
2937
3791
  }
2938
3792
  }
@@ -2941,89 +3795,44 @@ class CraftBridge(
2941
3795
 
2942
3796
  @JavascriptInterface
2943
3797
  fun registerBackgroundTask(taskId: String) {
2944
- try {
2945
- // WorkManager tasks don't require explicit registration, just track them
2946
- activity.runOnUiThread {
2947
- webView.evaluateJavascript(
2948
- "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', registered: true})",
2949
- null
2950
- )
2951
- }
2952
- } catch (e: Exception) {
2953
- activity.runOnUiThread {
2954
- webView.evaluateJavascript(
2955
- "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2956
- null
2957
- )
2958
- }
2959
- }
3798
+ rejectBackgroundTask(taskId)
2960
3799
  }
2961
3800
 
2962
3801
  @JavascriptInterface
3802
+ @Suppress("UNUSED_PARAMETER")
2963
3803
  fun scheduleBackgroundTask(taskId: String, delay: Long, requiresNetwork: Boolean, requiresCharging: Boolean) {
2964
- try {
2965
- // Note: Full WorkManager implementation requires adding the dependency
2966
- // implementation 'androidx.work:work-runtime-ktx:2.9.0'
2967
- // This is a placeholder that shows the API structure
2968
- activity.runOnUiThread {
2969
- webView.evaluateJavascript(
2970
- "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', scheduled: true, delay: $delay})",
2971
- null
2972
- )
2973
- }
2974
- } catch (e: Exception) {
2975
- activity.runOnUiThread {
2976
- webView.evaluateJavascript(
2977
- "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2978
- null
2979
- )
2980
- }
2981
- }
3804
+ rejectBackgroundTask(taskId)
2982
3805
  }
2983
3806
 
2984
3807
  @JavascriptInterface
2985
3808
  fun cancelBackgroundTask(taskId: String) {
2986
- try {
2987
- activity.runOnUiThread {
2988
- webView.evaluateJavascript(
2989
- "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', cancelled: true})",
2990
- null
2991
- )
2992
- }
2993
- } catch (e: Exception) {
2994
- activity.runOnUiThread {
2995
- webView.evaluateJavascript(
2996
- "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2997
- null
2998
- )
2999
- }
3000
- }
3809
+ rejectBackgroundTask(taskId)
3001
3810
  }
3002
3811
 
3003
3812
  @JavascriptInterface
3004
3813
  fun cancelAllBackgroundTasks() {
3005
- try {
3006
- activity.runOnUiThread {
3007
- webView.evaluateJavascript(
3008
- "window._craftBgTaskResolve && window._craftBgTaskResolve({cancelled: true})",
3009
- null
3010
- )
3011
- }
3012
- } catch (e: Exception) {
3013
- activity.runOnUiThread {
3014
- webView.evaluateJavascript(
3015
- "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3016
- null
3017
- )
3018
- }
3814
+ rejectBackgroundTask()
3815
+ }
3816
+
3817
+ private fun rejectBackgroundTask(taskId: String? = null) {
3818
+ val message = if (taskId == null) {
3819
+ "Background tasks are unavailable on Android"
3820
+ } else {
3821
+ "Background task $taskId is unavailable on Android"
3019
3822
  }
3823
+ evaluatePromiseJavascript(
3824
+ "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(message)})"
3825
+ )
3020
3826
  }
3021
3827
 
3022
3828
  // ==================== PDF Viewer ====================
3023
3829
 
3024
3830
  @JavascriptInterface
3025
3831
  fun openPDF(source: String, page: Int) {
3832
+ if (CraftNative.openPDF(activity, source, page)) return
3833
+
3026
3834
  activity.runOnUiThread {
3835
+ if (closed) return@runOnUiThread
3027
3836
  try {
3028
3837
  val uri = if (source.startsWith("data:")) {
3029
3838
  // Save base64 to temp file and open
@@ -3043,14 +3852,12 @@ class CraftBridge(
3043
3852
  }
3044
3853
 
3045
3854
  activity.startActivity(intent)
3046
- webView.evaluateJavascript(
3047
- "window._craftPDFResolve && window._craftPDFResolve({opened: true})",
3048
- null
3855
+ evaluatePromiseJavascript(
3856
+ "window._craftPDFResolve && window._craftPDFResolve({opened: true})"
3049
3857
  )
3050
3858
  } catch (e: Exception) {
3051
- webView.evaluateJavascript(
3052
- "window._craftPDFReject && window._craftPDFReject('${e.message}')",
3053
- null
3859
+ evaluatePromiseJavascript(
3860
+ "window._craftPDFReject && window._craftPDFReject(${jsQuote(e.message ?: "PDF could not be opened")})"
3054
3861
  )
3055
3862
  }
3056
3863
  }
@@ -3059,38 +3866,39 @@ class CraftBridge(
3059
3866
  @JavascriptInterface
3060
3867
  fun closePDF() {
3061
3868
  // PDFs are opened in external viewers, so we just acknowledge
3062
- activity.runOnUiThread {
3063
- webView.evaluateJavascript(
3064
- "window._craftPDFResolve && window._craftPDFResolve(true)",
3065
- null
3066
- )
3067
- }
3869
+ evaluatePromiseJavascript("window._craftPDFResolve && window._craftPDFResolve(true)")
3068
3870
  }
3069
3871
 
3070
3872
  // ==================== Contacts Picker ====================
3071
3873
 
3072
3874
  @JavascriptInterface
3073
3875
  fun pickContact(multiple: Boolean) {
3876
+ // `multiple` is ignored by both implementations: Android still opens
3877
+ // the platform's single-contact ACTION_PICK surface.
3074
3878
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
3075
3879
  != PackageManager.PERMISSION_GRANTED) {
3076
- ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_PICK_CONTACT)
3880
+ rejectContactPicker("Contacts permission is required; retry after granting it")
3881
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_PICK_CONTACT)
3077
3882
  return
3078
3883
  }
3079
3884
 
3885
+ if (CraftNative.pickContact(activity)) return
3886
+
3080
3887
  activity.runOnUiThread {
3081
- val intent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
3082
- activity.startActivityForResult(intent, REQUEST_PICK_CONTACT)
3888
+ if (closed) return@runOnUiThread
3889
+ try {
3890
+ val intent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
3891
+ activity.startActivityForResult(intent, REQUEST_PICK_CONTACT)
3892
+ } catch (error: Exception) {
3893
+ rejectContactPicker(error.message ?: "Contact picker could not be opened")
3894
+ }
3083
3895
  }
3084
3896
  }
3085
3897
 
3086
3898
  fun handleContactPickerResult(resultCode: Int, data: Intent?) {
3899
+ if (closed) return
3087
3900
  if (resultCode != Activity.RESULT_OK || data?.data == null) {
3088
- activity.runOnUiThread {
3089
- webView.evaluateJavascript(
3090
- "window._craftPickContactReject && window._craftPickContactReject('Cancelled')",
3091
- null
3092
- )
3093
- }
3901
+ rejectContactPicker("Cancelled")
3094
3902
  return
3095
3903
  }
3096
3904
 
@@ -3101,77 +3909,94 @@ class CraftBridge(
3101
3909
  ContactsContract.Contacts.DISPLAY_NAME
3102
3910
  )
3103
3911
 
3104
- activity.contentResolver.query(contactUri, projection, null, null, null)?.use { cursor ->
3105
- if (cursor.moveToFirst()) {
3106
- val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
3107
- val displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
3108
-
3109
- // Get phone numbers
3110
- val phones = mutableListOf<Map<String, String>>()
3111
- activity.contentResolver.query(
3112
- ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
3113
- arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE),
3114
- "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
3115
- arrayOf(contactId),
3116
- null
3117
- )?.use { phoneCursor ->
3118
- while (phoneCursor.moveToNext()) {
3119
- val number = phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
3120
- phones.add(mapOf("number" to number))
3121
- }
3122
- }
3912
+ val contact = activity.contentResolver.query(contactUri, projection, null, null, null)?.use { cursor ->
3913
+ if (!cursor.moveToFirst()) return@use null
3123
3914
 
3124
- // Get emails
3125
- val emails = mutableListOf<Map<String, String>>()
3126
- activity.contentResolver.query(
3127
- ContactsContract.CommonDataKinds.Email.CONTENT_URI,
3128
- arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS),
3129
- "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?",
3130
- arrayOf(contactId),
3131
- null
3132
- )?.use { emailCursor ->
3133
- while (emailCursor.moveToNext()) {
3134
- val address = emailCursor.getString(emailCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
3135
- emails.add(mapOf("address" to address))
3136
- }
3137
- }
3915
+ val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
3916
+ val displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
3138
3917
 
3139
- val result = JSONObject().apply {
3140
- put("id", contactId)
3141
- put("displayName", displayName)
3142
- put("phoneNumbers", JSONArray(phones.map { JSONObject(it) }))
3143
- put("emailAddresses", JSONArray(emails.map { JSONObject(it) }))
3918
+ // Get phone numbers
3919
+ val phones = mutableListOf<Map<String, String>>()
3920
+ activity.contentResolver.query(
3921
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
3922
+ arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE),
3923
+ "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
3924
+ arrayOf(contactId),
3925
+ null
3926
+ )?.use { phoneCursor ->
3927
+ while (phoneCursor.moveToNext()) {
3928
+ val number = phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
3929
+ phones.add(mapOf("number" to number))
3144
3930
  }
3931
+ }
3145
3932
 
3146
- activity.runOnUiThread {
3147
- webView.evaluateJavascript(
3148
- "window._craftPickContactResolve && window._craftPickContactResolve($result)",
3149
- null
3150
- )
3933
+ // Get emails
3934
+ val emails = mutableListOf<Map<String, String>>()
3935
+ activity.contentResolver.query(
3936
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
3937
+ arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS),
3938
+ "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?",
3939
+ arrayOf(contactId),
3940
+ null
3941
+ )?.use { emailCursor ->
3942
+ while (emailCursor.moveToNext()) {
3943
+ val address = emailCursor.getString(emailCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
3944
+ emails.add(mapOf("address" to address))
3151
3945
  }
3152
3946
  }
3947
+
3948
+ JSONObject().apply {
3949
+ put("id", contactId)
3950
+ put("displayName", displayName)
3951
+ put("phoneNumbers", JSONArray(phones.map { JSONObject(it) }))
3952
+ put("emailAddresses", JSONArray(emails.map { JSONObject(it) }))
3953
+ }
3153
3954
  }
3154
- } catch (e: Exception) {
3155
- activity.runOnUiThread {
3156
- webView.evaluateJavascript(
3157
- "window._craftPickContactReject && window._craftPickContactReject('${e.message}')",
3158
- null
3159
- )
3955
+
3956
+ if (contact == null) {
3957
+ rejectContactPicker("Selected contact could not be read")
3958
+ return
3160
3959
  }
3960
+
3961
+ evaluatePromiseJavascript(
3962
+ "window._craftPickContactResolve && window._craftPickContactResolve($contact)"
3963
+ )
3964
+ } catch (e: Exception) {
3965
+ evaluatePromiseJavascript(
3966
+ "window._craftPickContactReject && window._craftPickContactReject(${jsQuote(e.message ?: "Selected contact could not be read")})"
3967
+ )
3161
3968
  }
3162
3969
  }
3163
3970
 
3971
+ private fun rejectContactPicker(message: String) {
3972
+ evaluatePromiseJavascript(
3973
+ "window._craftPickContactReject && window._craftPickContactReject(${jsQuote(message)})"
3974
+ )
3975
+ }
3976
+
3164
3977
  // ==================== App Shortcuts ====================
3165
3978
 
3979
+ fun dispatchShortcut(type: String) {
3980
+ activity.runOnUiThread {
3981
+ val data = mapOf<String, Any>("type" to type)
3982
+ if (bridgeReady) {
3983
+ sendEvent("craftShortcut", data)
3984
+ } else {
3985
+ pendingEvents.add("craftShortcut" to data)
3986
+ }
3987
+ }
3988
+ }
3989
+
3166
3990
  @JavascriptInterface
3167
3991
  fun setShortcuts(shortcutsJson: String) {
3992
+ // Zig answers through the reply channel, so returning here is what
3993
+ // stops the promise being settled twice.
3994
+ if (CraftNative.setShortcuts(activity, shortcutsJson)) return
3995
+
3168
3996
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
3169
- activity.runOnUiThread {
3170
- webView.evaluateJavascript(
3171
- "window._craftShortcutsReject && window._craftShortcutsReject('App shortcuts require Android 7.1+')",
3172
- null
3173
- )
3174
- }
3997
+ evaluatePromiseJavascript(
3998
+ "window._craftShortcutsReject && window._craftShortcutsReject('App shortcuts require Android 7.1+')"
3999
+ )
3175
4000
  return
3176
4001
  }
3177
4002
 
@@ -3202,50 +4027,33 @@ class CraftBridge(
3202
4027
 
3203
4028
  shortcutManager?.dynamicShortcuts = shortcuts
3204
4029
 
3205
- activity.runOnUiThread {
3206
- webView.evaluateJavascript(
3207
- "window._craftShortcutsResolve && window._craftShortcutsResolve({count: ${shortcuts.size}})",
3208
- null
3209
- )
3210
- }
4030
+ evaluatePromiseJavascript(
4031
+ "window._craftShortcutsResolve && window._craftShortcutsResolve({set: true, count: ${shortcuts.size}})"
4032
+ )
3211
4033
  } catch (e: Exception) {
3212
- activity.runOnUiThread {
3213
- webView.evaluateJavascript(
3214
- "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3215
- null
3216
- )
3217
- }
4034
+ evaluatePromiseJavascript(
4035
+ "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message ?: "Shortcuts could not be set")})"
4036
+ )
3218
4037
  }
3219
4038
  }
3220
4039
 
3221
4040
  @JavascriptInterface
3222
4041
  fun clearShortcuts() {
4042
+ if (CraftNative.clearShortcuts(activity)) return
4043
+
3223
4044
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
3224
4045
  try {
3225
4046
  val shortcutManager = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
3226
4047
  shortcutManager?.removeAllDynamicShortcuts()
3227
4048
 
3228
- activity.runOnUiThread {
3229
- webView.evaluateJavascript(
3230
- "window._craftShortcutsResolve && window._craftShortcutsResolve(true)",
3231
- null
3232
- )
3233
- }
4049
+ evaluatePromiseJavascript("window._craftShortcutsResolve && window._craftShortcutsResolve({cleared: true})")
3234
4050
  } catch (e: Exception) {
3235
- activity.runOnUiThread {
3236
- webView.evaluateJavascript(
3237
- "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3238
- null
3239
- )
3240
- }
3241
- }
3242
- } else {
3243
- activity.runOnUiThread {
3244
- webView.evaluateJavascript(
3245
- "window._craftShortcutsResolve && window._craftShortcutsResolve(true)",
3246
- null
4051
+ evaluatePromiseJavascript(
4052
+ "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message ?: "Shortcuts could not be cleared")})"
3247
4053
  )
3248
4054
  }
4055
+ } else {
4056
+ evaluatePromiseJavascript("window._craftShortcutsResolve && window._craftShortcutsResolve({cleared: true})")
3249
4057
  }
3250
4058
  }
3251
4059
 
@@ -3253,111 +4061,95 @@ class CraftBridge(
3253
4061
 
3254
4062
  @JavascriptInterface
3255
4063
  fun setSharedItem(key: String, value: String, group: String) {
4064
+ // Zig answers through the reply channel, so returning here is what
4065
+ // stops the promise being settled twice.
4066
+ if (CraftNative.setSharedItem(activity, key, value, group)) return
4067
+
3256
4068
  try {
3257
4069
  val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3258
4070
  val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3259
4071
  prefs.edit().putString(key, value).apply()
3260
4072
 
3261
- activity.runOnUiThread {
3262
- webView.evaluateJavascript(
3263
- "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3264
- null
3265
- )
3266
- }
4073
+ evaluatePromiseJavascript(
4074
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({set: true, key: ${jsQuote(key)}})"
4075
+ )
3267
4076
  } catch (e: Exception) {
3268
- activity.runOnUiThread {
3269
- webView.evaluateJavascript(
3270
- "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3271
- null
3272
- )
3273
- }
4077
+ evaluatePromiseJavascript(
4078
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message ?: "Shared item could not be stored")})"
4079
+ )
3274
4080
  }
3275
4081
  }
3276
4082
 
3277
4083
  @JavascriptInterface
3278
4084
  fun getSharedItem(key: String, group: String) {
4085
+ // Zig answers through the reply channel, so returning here is what
4086
+ // stops the promise being settled twice.
4087
+ if (CraftNative.getSharedItem(activity, key, group)) return
4088
+
3279
4089
  try {
3280
4090
  val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3281
4091
  val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3282
4092
  val value = prefs.getString(key, null)
3283
4093
 
3284
- activity.runOnUiThread {
3285
- if (value != null) {
3286
- webView.evaluateJavascript(
3287
- "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: '$value', key: '$key'})",
3288
- null
3289
- )
3290
- } else {
3291
- webView.evaluateJavascript(
3292
- "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: '$key'})",
3293
- null
3294
- )
3295
- }
4094
+ val payload = if (value != null) {
4095
+ "{value: ${jsQuote(value)}, key: ${jsQuote(key)}}"
4096
+ } else {
4097
+ "{value: null, key: ${jsQuote(key)}}"
3296
4098
  }
4099
+ evaluatePromiseJavascript(
4100
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve($payload)"
4101
+ )
3297
4102
  } catch (e: Exception) {
3298
- activity.runOnUiThread {
3299
- webView.evaluateJavascript(
3300
- "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3301
- null
3302
- )
3303
- }
4103
+ evaluatePromiseJavascript(
4104
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message ?: "Shared item could not be read")})"
4105
+ )
3304
4106
  }
3305
4107
  }
3306
4108
 
3307
4109
  @JavascriptInterface
3308
4110
  fun removeSharedItem(key: String, group: String) {
4111
+ // Zig answers through the reply channel, so returning here is what
4112
+ // stops the promise being settled twice.
4113
+ if (CraftNative.removeSharedItem(activity, key, group)) return
4114
+
3309
4115
  try {
3310
4116
  val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3311
4117
  val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3312
4118
  prefs.edit().remove(key).apply()
3313
4119
 
3314
- activity.runOnUiThread {
3315
- webView.evaluateJavascript(
3316
- "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3317
- null
3318
- )
3319
- }
4120
+ evaluatePromiseJavascript(
4121
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({removed: true, key: ${jsQuote(key)}})"
4122
+ )
3320
4123
  } catch (e: Exception) {
3321
- activity.runOnUiThread {
3322
- webView.evaluateJavascript(
3323
- "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3324
- null
3325
- )
3326
- }
4124
+ evaluatePromiseJavascript(
4125
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message ?: "Shared item could not be removed")})"
4126
+ )
3327
4127
  }
3328
4128
  }
3329
4129
 
3330
4130
  // ==================== Local Auth Persistence ====================
3331
4131
 
3332
- private var authSessionExpiry: Long = 0L
4132
+ private val authSessionExpiryKey = "craft_auth_session_expiry"
3333
4133
 
3334
4134
  @JavascriptInterface
3335
4135
  fun setAuthPersistence(enabled: Boolean, duration: Long) {
3336
4136
  try {
3337
4137
  if (enabled) {
3338
- authSessionExpiry = System.currentTimeMillis() + (duration * 1000)
3339
- activity.runOnUiThread {
3340
- webView.evaluateJavascript(
3341
- "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: true, duration: $duration, expiresAt: $authSessionExpiry})",
3342
- null
3343
- )
3344
- }
4138
+ val expiresAt = System.currentTimeMillis() + (duration * 1000)
4139
+ securePrefs.edit().putLong(authSessionExpiryKey, expiresAt).apply()
4140
+ evaluatePromiseJavascript(
4141
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: true, duration: $duration, expiresAt: $expiresAt})"
4142
+ )
3345
4143
  } else {
3346
- authSessionExpiry = 0L
3347
- activity.runOnUiThread {
3348
- webView.evaluateJavascript(
3349
- "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: false})",
3350
- null
3351
- )
3352
- }
3353
- }
3354
- } catch (e: Exception) {
3355
- activity.runOnUiThread {
3356
- webView.evaluateJavascript(
3357
- "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3358
- null
4144
+ securePrefs.edit().remove(authSessionExpiryKey).apply()
4145
+ evaluatePromiseJavascript(
4146
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: false})"
3359
4147
  )
3360
4148
  }
4149
+ } catch (e: Exception) {
4150
+ evaluatePromiseJavascript(
4151
+ "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message ?: "Auth persistence could not be updated")})"
4152
+ )
3361
4153
  }
3362
4154
  }
3363
4155
 
@@ -3365,43 +4157,32 @@ class CraftBridge(
3365
4157
  fun checkAuthPersistence() {
3366
4158
  try {
3367
4159
  val now = System.currentTimeMillis()
4160
+ val authSessionExpiry = securePrefs.getLong(authSessionExpiryKey, 0L)
3368
4161
  val isValid = authSessionExpiry > now
3369
4162
  val remainingMs = if (isValid) authSessionExpiry - now else 0L
3370
4163
  val remainingSeconds = remainingMs / 1000
3371
4164
 
3372
- activity.runOnUiThread {
3373
- webView.evaluateJavascript(
3374
- "window._craftAuthPersistResolve && window._craftAuthPersistResolve({isValid: $isValid, remainingSeconds: $remainingSeconds})",
3375
- null
3376
- )
3377
- }
4165
+ evaluatePromiseJavascript(
4166
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({isValid: $isValid, remainingSeconds: $remainingSeconds})"
4167
+ )
3378
4168
  } catch (e: Exception) {
3379
- activity.runOnUiThread {
3380
- webView.evaluateJavascript(
3381
- "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3382
- null
3383
- )
3384
- }
4169
+ evaluatePromiseJavascript(
4170
+ "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message ?: "Auth persistence could not be checked")})"
4171
+ )
3385
4172
  }
3386
4173
  }
3387
4174
 
3388
4175
  @JavascriptInterface
3389
4176
  fun clearAuthPersistence() {
3390
4177
  try {
3391
- authSessionExpiry = 0L
3392
- activity.runOnUiThread {
3393
- webView.evaluateJavascript(
3394
- "window._craftAuthPersistResolve && window._craftAuthPersistResolve({cleared: true})",
3395
- null
3396
- )
3397
- }
4178
+ securePrefs.edit().remove(authSessionExpiryKey).apply()
4179
+ evaluatePromiseJavascript(
4180
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({cleared: true})"
4181
+ )
3398
4182
  } catch (e: Exception) {
3399
- activity.runOnUiThread {
3400
- webView.evaluateJavascript(
3401
- "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3402
- null
3403
- )
3404
- }
4183
+ evaluatePromiseJavascript(
4184
+ "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message ?: "Auth persistence could not be cleared")})"
4185
+ )
3405
4186
  }
3406
4187
  }
3407
4188
 
@@ -3412,58 +4193,60 @@ class CraftBridge(
3412
4193
 
3413
4194
  @JavascriptInterface
3414
4195
  fun startAR(optionsJson: String) {
3415
- activity.runOnUiThread {
3416
- webView.evaluateJavascript(
3417
- "window._craftARReject && window._craftARReject('ARCore requires native Activity integration. Use Sceneform or AR Fragment for full AR support.')",
3418
- null
3419
- )
3420
- }
4196
+ evaluatePromiseJavascript(
4197
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration. Use Sceneform or AR Fragment for full AR support.')"
4198
+ )
3421
4199
  }
3422
4200
 
3423
4201
  @JavascriptInterface
3424
4202
  fun stopAR() {
3425
- activity.runOnUiThread {
3426
- webView.evaluateJavascript(
3427
- "window._craftARResolve && window._craftARResolve({stopped: true})",
3428
- null
3429
- )
3430
- }
4203
+ evaluatePromiseJavascript("window._craftARResolve && window._craftARResolve({stopped: true})")
3431
4204
  }
3432
4205
 
3433
4206
  @JavascriptInterface
3434
4207
  fun placeARObject(model: String, positionJson: String) {
3435
- activity.runOnUiThread {
3436
- webView.evaluateJavascript(
3437
- "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')",
3438
- null
3439
- )
3440
- }
4208
+ evaluatePromiseJavascript(
4209
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')"
4210
+ )
3441
4211
  }
3442
4212
 
3443
4213
  @JavascriptInterface
3444
4214
  fun removeARObject(objectId: String) {
3445
- activity.runOnUiThread {
3446
- webView.evaluateJavascript(
3447
- "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')",
3448
- null
3449
- )
3450
- }
4215
+ evaluatePromiseJavascript(
4216
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')"
4217
+ )
3451
4218
  }
3452
4219
 
3453
4220
  @JavascriptInterface
3454
4221
  fun getARPlanes() {
3455
- activity.runOnUiThread {
3456
- webView.evaluateJavascript(
3457
- "window._craftARResolve && window._craftARResolve([])",
3458
- null
3459
- )
3460
- }
4222
+ evaluatePromiseJavascript("window._craftARResolve && window._craftARResolve([])")
3461
4223
  }
3462
4224
 
3463
4225
  // ==================== ML Kit ====================
3464
4226
 
4227
+ private fun createMlSettlement(): Pair<(JSONArray) -> Unit, (String) -> Unit> {
4228
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
4229
+ val resolve: (JSONArray) -> Unit = { results ->
4230
+ if (!closed && settled.compareAndSet(false, true)) {
4231
+ evaluatePromiseJavascript("window._craftMLResolve && window._craftMLResolve($results)")
4232
+ }
4233
+ }
4234
+ val reject: (String) -> Unit = { message ->
4235
+ if (!closed && settled.compareAndSet(false, true)) {
4236
+ evaluatePromiseJavascript(
4237
+ "window._craftMLReject && window._craftMLReject(${jsQuote(message)})"
4238
+ )
4239
+ }
4240
+ }
4241
+ return resolve to reject
4242
+ }
4243
+
3465
4244
  @JavascriptInterface
3466
4245
  fun classifyImage(imageBase64: String) {
4246
+ if (CraftNative.classifyImage(imageBase64)) return
4247
+
4248
+ val (resolve, reject) = createMlSettlement()
4249
+
3467
4250
  try {
3468
4251
  val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3469
4252
  val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
@@ -3472,41 +4255,33 @@ class CraftBridge(
3472
4255
  val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
3473
4256
  labeler.process(inputImage)
3474
4257
  .addOnSuccessListener { labels ->
3475
- val results = JSONArray()
3476
- for (label in labels) {
3477
- val obj = JSONObject()
3478
- obj.put("label", label.text)
3479
- obj.put("confidence", label.confidence)
3480
- obj.put("index", label.index)
3481
- results.put(obj)
3482
- }
3483
- activity.runOnUiThread {
3484
- webView.evaluateJavascript(
3485
- "window._craftMLResolve && window._craftMLResolve($results)",
3486
- null
3487
- )
3488
- }
3489
- }
3490
- .addOnFailureListener { e ->
3491
- activity.runOnUiThread {
3492
- webView.evaluateJavascript(
3493
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3494
- null
3495
- )
4258
+ try {
4259
+ val results = JSONArray()
4260
+ for (label in labels) {
4261
+ val obj = JSONObject()
4262
+ obj.put("label", label.text)
4263
+ obj.put("confidence", label.confidence)
4264
+ obj.put("index", label.index)
4265
+ results.put(obj)
4266
+ }
4267
+ resolve(results)
4268
+ } catch (error: Exception) {
4269
+ reject(error.message ?: "Image classification failed")
3496
4270
  }
3497
4271
  }
4272
+ .addOnFailureListener { error -> reject(error.message ?: "Image classification failed") }
4273
+ .addOnCompleteListener { labeler.close() }
3498
4274
  } catch (e: Exception) {
3499
- activity.runOnUiThread {
3500
- webView.evaluateJavascript(
3501
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3502
- null
3503
- )
3504
- }
4275
+ reject(e.message ?: "Image classification failed")
3505
4276
  }
3506
4277
  }
3507
4278
 
3508
4279
  @JavascriptInterface
3509
4280
  fun detectObjects(imageBase64: String) {
4281
+ if (CraftNative.detectObjects(imageBase64)) return
4282
+
4283
+ val (resolve, reject) = createMlSettlement()
4284
+
3510
4285
  try {
3511
4286
  val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3512
4287
  val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
@@ -3521,59 +4296,51 @@ class CraftBridge(
3521
4296
  val objectDetector = ObjectDetection.getClient(options)
3522
4297
  objectDetector.process(inputImage)
3523
4298
  .addOnSuccessListener { detectedObjects ->
3524
- val results = JSONArray()
3525
- for (obj in detectedObjects) {
3526
- val objJson = JSONObject()
3527
-
3528
- // Bounding box
3529
- val bbox = JSONObject()
3530
- bbox.put("x", obj.boundingBox.left)
3531
- bbox.put("y", obj.boundingBox.top)
3532
- bbox.put("width", obj.boundingBox.width())
3533
- bbox.put("height", obj.boundingBox.height())
3534
- objJson.put("boundingBox", bbox)
3535
-
3536
- // Labels
3537
- val labels = JSONArray()
3538
- for (label in obj.labels) {
3539
- val labelJson = JSONObject()
3540
- labelJson.put("label", label.text)
3541
- labelJson.put("confidence", label.confidence)
3542
- labelJson.put("index", label.index)
3543
- labels.put(labelJson)
3544
- }
3545
- objJson.put("labels", labels)
3546
- objJson.put("trackingId", obj.trackingId)
4299
+ try {
4300
+ val results = JSONArray()
4301
+ for (obj in detectedObjects) {
4302
+ val objJson = JSONObject()
4303
+
4304
+ // Bounding box
4305
+ val bbox = JSONObject()
4306
+ bbox.put("x", obj.boundingBox.left)
4307
+ bbox.put("y", obj.boundingBox.top)
4308
+ bbox.put("width", obj.boundingBox.width())
4309
+ bbox.put("height", obj.boundingBox.height())
4310
+ objJson.put("boundingBox", bbox)
4311
+
4312
+ // Labels
4313
+ val labels = JSONArray()
4314
+ for (label in obj.labels) {
4315
+ val labelJson = JSONObject()
4316
+ labelJson.put("label", label.text)
4317
+ labelJson.put("confidence", label.confidence)
4318
+ labelJson.put("index", label.index)
4319
+ labels.put(labelJson)
4320
+ }
4321
+ objJson.put("labels", labels)
4322
+ objJson.put("trackingId", obj.trackingId)
3547
4323
 
3548
- results.put(objJson)
3549
- }
3550
- activity.runOnUiThread {
3551
- webView.evaluateJavascript(
3552
- "window._craftMLResolve && window._craftMLResolve($results)",
3553
- null
3554
- )
3555
- }
3556
- }
3557
- .addOnFailureListener { e ->
3558
- activity.runOnUiThread {
3559
- webView.evaluateJavascript(
3560
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3561
- null
3562
- )
4324
+ results.put(objJson)
4325
+ }
4326
+ resolve(results)
4327
+ } catch (error: Exception) {
4328
+ reject(error.message ?: "Object detection failed")
3563
4329
  }
3564
4330
  }
4331
+ .addOnFailureListener { error -> reject(error.message ?: "Object detection failed") }
4332
+ .addOnCompleteListener { objectDetector.close() }
3565
4333
  } catch (e: Exception) {
3566
- activity.runOnUiThread {
3567
- webView.evaluateJavascript(
3568
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3569
- null
3570
- )
3571
- }
4334
+ reject(e.message ?: "Object detection failed")
3572
4335
  }
3573
4336
  }
3574
4337
 
3575
4338
  @JavascriptInterface
3576
4339
  fun recognizeText(imageBase64: String) {
4340
+ if (CraftNative.recognizeText(imageBase64)) return
4341
+
4342
+ val (resolve, reject) = createMlSettlement()
4343
+
3577
4344
  try {
3578
4345
  val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3579
4346
  val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
@@ -3582,48 +4349,36 @@ class CraftBridge(
3582
4349
  val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
3583
4350
  recognizer.process(inputImage)
3584
4351
  .addOnSuccessListener { visionText ->
3585
- val results = JSONArray()
3586
- for (block in visionText.textBlocks) {
3587
- for (line in block.lines) {
3588
- val lineJson = JSONObject()
3589
- lineJson.put("text", line.text)
3590
- lineJson.put("confidence", line.confidence ?: 0.0)
3591
-
3592
- val boundingBox = line.boundingBox
3593
- if (boundingBox != null) {
3594
- val bbox = JSONObject()
3595
- bbox.put("x", boundingBox.left)
3596
- bbox.put("y", boundingBox.top)
3597
- bbox.put("width", boundingBox.width())
3598
- bbox.put("height", boundingBox.height())
3599
- lineJson.put("boundingBox", bbox)
3600
- }
4352
+ try {
4353
+ val results = JSONArray()
4354
+ for (block in visionText.textBlocks) {
4355
+ for (line in block.lines) {
4356
+ val lineJson = JSONObject()
4357
+ lineJson.put("text", line.text)
4358
+ lineJson.put("confidence", line.confidence ?: 0.0)
4359
+
4360
+ val boundingBox = line.boundingBox
4361
+ if (boundingBox != null) {
4362
+ val bbox = JSONObject()
4363
+ bbox.put("x", boundingBox.left)
4364
+ bbox.put("y", boundingBox.top)
4365
+ bbox.put("width", boundingBox.width())
4366
+ bbox.put("height", boundingBox.height())
4367
+ lineJson.put("boundingBox", bbox)
4368
+ }
3601
4369
 
3602
- results.put(lineJson)
4370
+ results.put(lineJson)
4371
+ }
3603
4372
  }
3604
- }
3605
- activity.runOnUiThread {
3606
- webView.evaluateJavascript(
3607
- "window._craftMLResolve && window._craftMLResolve($results)",
3608
- null
3609
- )
3610
- }
3611
- }
3612
- .addOnFailureListener { e ->
3613
- activity.runOnUiThread {
3614
- webView.evaluateJavascript(
3615
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3616
- null
3617
- )
4373
+ resolve(results)
4374
+ } catch (error: Exception) {
4375
+ reject(error.message ?: "Text recognition failed")
3618
4376
  }
3619
4377
  }
4378
+ .addOnFailureListener { error -> reject(error.message ?: "Text recognition failed") }
4379
+ .addOnCompleteListener { recognizer.close() }
3620
4380
  } catch (e: Exception) {
3621
- activity.runOnUiThread {
3622
- webView.evaluateJavascript(
3623
- "window._craftMLReject && window._craftMLReject('${e.message}')",
3624
- null
3625
- )
3626
- }
4381
+ reject(e.message ?: "Text recognition failed")
3627
4382
  }
3628
4383
  }
3629
4384
 
@@ -3635,6 +4390,11 @@ class CraftBridge(
3635
4390
 
3636
4391
  @JavascriptInterface
3637
4392
  fun updateWidget(dataJson: String) {
4393
+ // Zig answers through the reply channel, so returning here is what
4394
+ // stops the promise being settled twice. The action is passed across
4395
+ // rather than rebuilt there — see CraftNative.updateWidget.
4396
+ if (CraftNative.updateWidget(activity, "{{PACKAGE_NAME}}.WIDGET_UPDATE", dataJson)) return
4397
+
3638
4398
  try {
3639
4399
  val data = JSONObject(dataJson)
3640
4400
  val editor = widgetPrefs.edit()
@@ -3659,66 +4419,37 @@ class CraftBridge(
3659
4419
  intent.setPackage(activity.packageName)
3660
4420
  activity.sendBroadcast(intent)
3661
4421
 
3662
- activity.runOnUiThread {
3663
- webView.evaluateJavascript(
3664
- "window._craftWidgetResolve && window._craftWidgetResolve({updated: true})",
3665
- null
3666
- )
3667
- }
4422
+ evaluatePromiseJavascript(
4423
+ "window._craftWidgetResolve && window._craftWidgetResolve({updated: true})"
4424
+ )
3668
4425
  } catch (e: Exception) {
3669
- activity.runOnUiThread {
3670
- webView.evaluateJavascript(
3671
- "window._craftWidgetReject && window._craftWidgetReject('${e.message}')",
3672
- null
3673
- )
3674
- }
4426
+ evaluatePromiseJavascript(
4427
+ "window._craftWidgetReject && window._craftWidgetReject(${jsQuote(e.message ?: "Widget update failed")})"
4428
+ )
3675
4429
  }
3676
4430
  }
3677
4431
 
3678
4432
  @JavascriptInterface
3679
4433
  fun reloadWidgets() {
3680
- val intent = Intent("{{PACKAGE_NAME}}.WIDGET_UPDATE")
3681
- intent.setPackage(activity.packageName)
3682
- activity.sendBroadcast(intent)
4434
+ if (CraftNative.reloadWidgets(activity, "{{PACKAGE_NAME}}.WIDGET_UPDATE")) return
3683
4435
 
3684
- activity.runOnUiThread {
3685
- webView.evaluateJavascript(
3686
- "window._craftWidgetResolve && window._craftWidgetResolve({reloaded: true})",
3687
- null
3688
- )
3689
- }
3690
- }
3691
-
3692
- // ==================== Google Assistant / App Actions ====================
3693
-
3694
- @JavascriptInterface
3695
- fun registerVoiceAction(phrase: String, action: String) {
3696
- // App Actions are defined in shortcuts.xml, not dynamically
3697
- // This stores the action for handling incoming intents
3698
- val prefs = activity.getSharedPreferences("craft_voice_actions", Context.MODE_PRIVATE)
3699
- prefs.edit().putString(action, phrase).apply()
4436
+ try {
4437
+ val intent = Intent("{{PACKAGE_NAME}}.WIDGET_UPDATE")
4438
+ intent.setPackage(activity.packageName)
4439
+ activity.sendBroadcast(intent)
3700
4440
 
3701
- activity.runOnUiThread {
3702
- webView.evaluateJavascript(
3703
- "window._craftVoiceResolve && window._craftVoiceResolve({registered: true, action: '$action', phrase: '$phrase'})",
3704
- null
4441
+ evaluatePromiseJavascript(
4442
+ "window._craftWidgetResolve && window._craftWidgetResolve({reloaded: true})"
3705
4443
  )
3706
- }
3707
- }
3708
-
3709
- @JavascriptInterface
3710
- fun removeVoiceAction(action: String) {
3711
- val prefs = activity.getSharedPreferences("craft_voice_actions", Context.MODE_PRIVATE)
3712
- prefs.edit().remove(action).apply()
3713
-
3714
- activity.runOnUiThread {
3715
- webView.evaluateJavascript(
3716
- "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: '$action'})",
3717
- null
4444
+ } catch (error: Exception) {
4445
+ evaluatePromiseJavascript(
4446
+ "window._craftWidgetReject && window._craftWidgetReject(${jsQuote(error.message ?: "Widget reload failed")})"
3718
4447
  )
3719
4448
  }
3720
4449
  }
3721
4450
 
4451
+ // ==================== Google Assistant / App Actions ====================
4452
+
3722
4453
  // Handle incoming voice action from Google Assistant
3723
4454
  fun handleVoiceAction(intent: Intent?) {
3724
4455
  intent?.let {
@@ -3726,12 +4457,9 @@ class CraftBridge(
3726
4457
  val data = it.dataString
3727
4458
 
3728
4459
  if (action != null) {
3729
- activity.runOnUiThread {
3730
- webView.evaluateJavascript(
3731
- "window.dispatchEvent(new CustomEvent('craftVoiceAction', {detail: {action: '$action', data: '$data'}}));",
3732
- null
3733
- )
3734
- }
4460
+ evaluateJavascriptUnlessClosed(
4461
+ "window.dispatchEvent(new CustomEvent('craftVoiceAction', {detail: {action: ${jsQuote(action)}, data: ${jsQuote(data)}}}));"
4462
+ )
3735
4463
  }
3736
4464
  }
3737
4465
  }
@@ -3742,23 +4470,17 @@ class CraftBridge(
3742
4470
  fun sendToWatch(messageJson: String) {
3743
4471
  // Note: Full Wearable API requires separate Wear OS app
3744
4472
  // This provides placeholder API for future integration
3745
- activity.runOnUiThread {
3746
- webView.evaluateJavascript(
3747
- "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')",
3748
- null
3749
- )
3750
- }
4473
+ evaluatePromiseJavascript(
4474
+ "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')"
4475
+ )
3751
4476
  }
3752
4477
 
3753
4478
  @JavascriptInterface
3754
4479
  fun updateWatchContext(contextJson: String) {
3755
4480
  // Note: Full Wearable API requires Data Layer API setup
3756
- activity.runOnUiThread {
3757
- webView.evaluateJavascript(
3758
- "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')",
3759
- null
3760
- )
3761
- }
4481
+ evaluatePromiseJavascript(
4482
+ "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')"
4483
+ )
3762
4484
  }
3763
4485
 
3764
4486
  @JavascriptInterface
@@ -3772,11 +4494,14 @@ class CraftBridge(
3772
4494
  private var initialURL: String? = null
3773
4495
 
3774
4496
  fun setInitialURL(url: String?) {
4497
+ if (nativeDeepLinks && CraftNative.setInitialURL(url)) return
3775
4498
  initialURL = url
3776
4499
  }
3777
4500
 
3778
4501
  @JavascriptInterface
3779
4502
  fun getInitialURL() {
4503
+ if (nativeDeepLinks && CraftNative.getInitialURL()) return
4504
+
3780
4505
  activity.runOnUiThread {
3781
4506
  if (initialURL != null) {
3782
4507
  val uri = Uri.parse(initialURL)
@@ -3793,20 +4518,20 @@ class CraftBridge(
3793
4518
  }
3794
4519
  put("queryParams", queryParams)
3795
4520
  }
3796
- webView.evaluateJavascript(
3797
- "window._craftDeepLinkResolve && window._craftDeepLinkResolve($json)",
3798
- null
4521
+ evaluatePromiseJavascript(
4522
+ "window._craftDeepLinkResolve && window._craftDeepLinkResolve($json)"
3799
4523
  )
3800
4524
  } else {
3801
- webView.evaluateJavascript(
3802
- "window._craftDeepLinkResolve && window._craftDeepLinkResolve(null)",
3803
- null
4525
+ evaluatePromiseJavascript(
4526
+ "window._craftDeepLinkResolve && window._craftDeepLinkResolve(null)"
3804
4527
  )
3805
4528
  }
3806
4529
  }
3807
4530
  }
3808
4531
 
3809
4532
  fun dispatchDeepLink(url: String) {
4533
+ if (nativeDeepLinks && CraftNative.dispatchDeepLink(url)) return
4534
+
3810
4535
  // Store as initial URL if this is the first one
3811
4536
  if (initialURL == null) {
3812
4537
  initialURL = url
@@ -3827,18 +4552,17 @@ class CraftBridge(
3827
4552
  put("queryParams", queryParams)
3828
4553
  }
3829
4554
 
3830
- activity.runOnUiThread {
3831
- webView.evaluateJavascript(
3832
- "window.dispatchEvent(new CustomEvent('craftDeepLink', {detail: $json}));",
3833
- null
3834
- )
3835
- }
4555
+ evaluateJavascriptUnlessClosed(
4556
+ "window.dispatchEvent(new CustomEvent('craftDeepLink', {detail: $json}));"
4557
+ )
3836
4558
  }
3837
4559
 
3838
4560
  // ==================== Performance Profiling ====================
3839
4561
 
3840
4562
  @JavascriptInterface
3841
4563
  fun getMemoryUsage(): String {
4564
+ CraftNative.getMemoryUsage()?.let { return it }
4565
+
3842
4566
  val runtime = Runtime.getRuntime()
3843
4567
  val usedMemory = runtime.totalMemory() - runtime.freeMemory()
3844
4568
  val maxMemory = runtime.maxMemory()
@@ -3855,17 +4579,48 @@ class CraftBridge(
3855
4579
 
3856
4580
  // ==================== Helpers ====================
3857
4581
 
3858
- private fun sendEvent(event: String, data: Map<String, Any>) {
3859
- val json = JSONObject(data).toString()
4582
+ /**
4583
+ * Give Zig a way to reach the page.
4584
+ *
4585
+ * The lambda is this class's own `runOnUiThread`/`evaluateJavascript`
4586
+ * pair, which is what `sendEvent` below already does — so Zig and Kotlin
4587
+ * deliver through exactly one implementation rather than two that have to
4588
+ * agree about threading.
4589
+ */
4590
+ private fun installNativeDeliverer() {
4591
+ CraftNative.setDeliverer { script ->
4592
+ evaluateJavascriptUnlessClosed(script)
4593
+ }
4594
+ }
4595
+
4596
+ private fun evaluatePromiseJavascript(script: String) {
4597
+ evaluateJavascriptUnlessClosed(script)
4598
+ }
4599
+
4600
+ private fun requestPermissionsBestEffort(permissions: Array<String>, requestCode: Int) {
3860
4601
  activity.runOnUiThread {
3861
- webView.evaluateJavascript(
3862
- "window.dispatchEvent(new CustomEvent('$event', {detail: $json}));",
3863
- null
3864
- )
4602
+ if (closed) return@runOnUiThread
4603
+ runCatching { ActivityCompat.requestPermissions(activity, permissions, requestCode) }
3865
4604
  }
3866
4605
  }
3867
4606
 
4607
+ private fun evaluateJavascriptUnlessClosed(script: String) {
4608
+ if (closed) return
4609
+ activity.runOnUiThread {
4610
+ if (closed) return@runOnUiThread
4611
+ webView.evaluateJavascript(script, null)
4612
+ }
4613
+ }
4614
+
4615
+ private fun sendEvent(event: String, data: Map<String, Any>) {
4616
+ val json = JSONObject(data).toString()
4617
+ evaluateJavascriptUnlessClosed(
4618
+ "window.dispatchEvent(new CustomEvent(${jsQuote(event)}, {detail: $json}));"
4619
+ )
4620
+ }
4621
+
3868
4622
  companion object {
4623
+ private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$")
3869
4624
  const val REQUEST_CAMERA = 1001
3870
4625
  const val REQUEST_GALLERY = 1002
3871
4626
  const val REQUEST_LOCATION = 1003
@@ -3876,5 +4631,8 @@ class CraftBridge(
3876
4631
  const val REQUEST_VIDEO = 1008
3877
4632
  const val REQUEST_BLUETOOTH = 1009
3878
4633
  const val REQUEST_PICK_CONTACT = 1010
4634
+ private const val PERMISSION_REQUEST_START = 2000
4635
+ private const val PERMISSION_REQUEST_END = 2999
4636
+ private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
3879
4637
  }
3880
4638
  }