craft-native 0.0.90 → 0.0.92

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