craft-native 0.0.91 → 0.0.94

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.
@@ -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
@@ -216,8 +219,43 @@ class CraftBridge(
216
219
  private val mainExecutor: Executor = ContextCompat.getMainExecutor(activity)
217
220
  @Volatile
218
221
  private var bridgeReady = false
222
+
223
+ /**
224
+ * Which navigation the bridge belongs to.
225
+ *
226
+ * `evaluateJavascript`'s completion is posted, so it can run after the
227
+ * next navigation has already started. Without this, a completion from
228
+ * the page that has just left marks the page that has just arrived as
229
+ * ready, and the queues below flush into a document with no bridge and no
230
+ * replay listener — where they are lost rather than delivered (#228).
231
+ *
232
+ * Main thread only, like everything that touches `bridgeReady`.
233
+ */
234
+ private var navigationGeneration = 0
219
235
  private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
220
236
 
237
+ // What the app was built with (#209). The page refuses a disabled call
238
+ // before it gets here; these are the second check, for a caller that
239
+ // reaches CraftAndroid directly, on the capabilities Android itself does
240
+ // not gate with a permission.
241
+ private val hapticsEnabled = {{ENABLE_HAPTICS}}
242
+ private val speechEnabled = {{ENABLE_SPEECH}}
243
+ private val shareEnabled = {{ENABLE_SHARE}}
244
+ private val keepAwakeEnabled = {{ENABLE_KEEP_AWAKE}}
245
+
246
+ private fun disabled(capability: String, call: String): Boolean {
247
+ android.util.Log.d("CraftBridge", "$call refused: $capability is disabled in craft.config.json")
248
+ return true
249
+ }
250
+
251
+ // Deep links (#215), on the UI thread only, the way iOS's DeepLinkManager
252
+ // holds them: a link that arrives before the first page has its bridge is
253
+ // the launch link, and every link waits for an injected bridge.
254
+ private var hasBeenReady = false
255
+ private var initialAssigned = false
256
+ private var launchURL: String? = null
257
+ private val pendingDeepLinks = mutableListOf<Pair<String, Boolean>>()
258
+
221
259
  // Location tracking
222
260
  private var fusedLocationClient: FusedLocationProviderClient? = null
223
261
  private val locationCallbacks = mutableMapOf<Int, LocationCallback>()
@@ -302,11 +340,33 @@ class CraftBridge(
302
340
  */
303
341
  private fun jsQuote(value: Any?): String = JSONObject.quote(value.toString())
304
342
 
343
+ /**
344
+ * The script, guarded so a second run in the *same* document does nothing.
345
+ *
346
+ * Chromium calls `onPageFinished` without a matching `onPageStarted` for
347
+ * same-document navigations — a fragment change, a `history.pushState` —
348
+ * and `MainActivity` injects on every one of them. Running the whole
349
+ * script again replaces `window.craft`, fires `craftReady` a second time,
350
+ * and rejects every in-flight promise with "Android bridge reinitialized"
351
+ * (#228).
352
+ *
353
+ * A block, not a function: `var` and function declarations are
354
+ * function-scoped, so the script behaves exactly as it does unwrapped,
355
+ * and everything it publishes it publishes on `window` regardless. A real
356
+ * navigation gets a new `window`, so the flag is absent and it installs.
357
+ */
358
+ private fun onlyOnce(script: String): String =
359
+ "if (!window.__craftBridgeInstalled) {\nwindow.__craftBridgeInstalled = true;\n$script\n}"
360
+
305
361
  fun injectBridge() {
306
362
  // Before the script, so a native callback arriving while the page is
307
363
  // still loading has somewhere to go rather than being counted as
308
364
  // dropped.
309
365
  installNativeDeliverer()
366
+ // A page is about to exist, so no later link launched the app, even
367
+ // one that arrives before this script's completion runs. iOS marks
368
+ // the same moment.
369
+ hasBeenReady = true
310
370
 
311
371
  val script = """
312
372
  {{PROMISE_RUNTIME}}
@@ -352,22 +412,39 @@ class CraftBridge(
352
412
  screenCapture: false
353
413
  },
354
414
 
355
- // Haptic feedback
415
+ // Haptic feedback.
416
+ //
417
+ // These four answer the page, as iOS does since #207. A
418
+ // @JavascriptInterface method answers its caller
419
+ // synchronously, so unlike the calls above there is no
420
+ // channel to park: the Kotlin method's own return value is
421
+ // the answer. The call goes inside `.then` so that a native
422
+ // failure rejects rather than throwing at the caller, which
423
+ // is the shape every other promise here has.
424
+ //
425
+ // `=== true` because a WebView reports some failures as
426
+ // `undefined` rather than as a throw, and `undefined` is
427
+ // exactly what #219 was about.
356
428
  haptic: function(style) {
357
- CraftAndroid.haptic(style || 'medium');
429
+ return Promise.resolve().then(function() { return CraftAndroid.haptic(style || 'medium') === true; });
358
430
  },
359
431
 
360
432
  // Speech recognition
361
433
  startListening: function() {
362
- CraftAndroid.startListening();
434
+ return Promise.resolve().then(function() { return CraftAndroid.startListening() === true; });
363
435
  },
364
436
  stopListening: function() {
365
- CraftAndroid.stopListening();
437
+ return Promise.resolve().then(function() { return CraftAndroid.stopListening() === true; });
366
438
  },
367
439
 
368
- // Share
440
+ // Share. Resolves true when the person picks an app, false
441
+ // when they dismiss the menu, and rejects when there is
442
+ // nothing to share or the menu cannot open. No timeout: the
443
+ // menu waits on a person, not on the device.
369
444
  share: function(text, title) {
370
- CraftAndroid.share(text, title || '');
445
+ return window.__craftPromise('share', '_craftShareResolve', '_craftShareReject', function() {
446
+ CraftAndroid.share(text == null ? '' : String(text), title == null ? '' : String(title));
447
+ });
371
448
  },
372
449
 
373
450
  // Camera
@@ -492,7 +569,7 @@ class CraftBridge(
492
569
 
493
570
  // Vibration Pattern
494
571
  vibrate: function(pattern) {
495
- CraftAndroid.vibrate(JSON.stringify(pattern));
572
+ return Promise.resolve().then(function() { return CraftAndroid.vibrate(JSON.stringify(pattern)) === true; });
496
573
  },
497
574
 
498
575
  // App State
@@ -590,7 +667,7 @@ class CraftBridge(
590
667
 
591
668
  // Deep Links
592
669
  onDeepLink: function(callback) {
593
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
670
+ return window.craft._subscribeDeepLinks(callback);
594
671
  },
595
672
 
596
673
  // QR/Barcode Scanner
@@ -991,12 +1068,13 @@ class CraftBridge(
991
1068
  // Deep Links
992
1069
  deepLinks: {
993
1070
  getInitialURL: function() {
1071
+ window.craft._claimInitialDeepLink();
994
1072
  return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() {
995
1073
  CraftAndroid.getInitialURL();
996
1074
  });
997
1075
  },
998
1076
  onLink: function(callback) {
999
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
1077
+ return window.craft._subscribeDeepLinks(callback);
1000
1078
  }
1001
1079
  },
1002
1080
 
@@ -1346,6 +1424,126 @@ class CraftBridge(
1346
1424
  }
1347
1425
  };
1348
1426
 
1427
+ // What the app was built with, enforced and not just reported
1428
+ // (#209). iOS refuses a call whose capability is off with
1429
+ // CAPABILITY_DISABLED, in Swift and in Zig; Android shaped the
1430
+ // manifest and craft.capabilities from the same flags and then
1431
+ // served the call anyway, so a page that feature-detected and a
1432
+ // page that just called got different behaviour.
1433
+ //
1434
+ // The refusal is made here, from the same capabilities object the
1435
+ // page can read, because that object is what this bridge promises.
1436
+ // Calls that answer nothing cannot carry a refusal, so they do
1437
+ // nothing and say so on the console, which is what a capability
1438
+ // the app never asked for should do. What is left answering
1439
+ // nothing is flashlight, keepAwake and the flat watch API (#220).
1440
+ //
1441
+ // Kotlin refuses the ungated-by-Android ones a second time, for a
1442
+ // caller that reaches CraftAndroid without going through here.
1443
+ (function installCapabilityGates(craft) {
1444
+ var gated = [
1445
+ ['haptic', 'haptics', true],
1446
+ ['vibrate', 'haptics', true],
1447
+ ['startListening', 'speechRecognition', true],
1448
+ ['setFlashlight', 'flashlight'],
1449
+ ['toggleFlashlight', 'flashlight'],
1450
+ ['setKeepAwake', 'keepAwake'],
1451
+ ['watchPosition', 'geolocation'],
1452
+ ['clearWatch', 'geolocation'],
1453
+ ['share', 'share', true],
1454
+ ['openCamera', 'camera', true],
1455
+ ['pickImage', 'camera', true],
1456
+ ['startVideoRecording', 'camera', true],
1457
+ ['authenticate', 'biometric', true],
1458
+ ['registerPush', 'pushNotifications', true],
1459
+ ['getCurrentPosition', 'geolocation', true],
1460
+ ['secureStore.set', 'secureStorage', true],
1461
+ ['secureStore.get', 'secureStorage', true],
1462
+ ['secureStore.remove', 'secureStorage', true],
1463
+ ['notifications.schedule', 'localNotifications', true],
1464
+ ['notifications.cancel', 'localNotifications', true],
1465
+ ['notifications.cancelAll', 'localNotifications', true],
1466
+ ['deepLinks.getInitialURL', 'deepLinks', true]
1467
+ ];
1468
+ function refusal(capability) {
1469
+ var error = new Error(capability + ' is disabled: this app was built without it');
1470
+ error.code = 'CAPABILITY_DISABLED';
1471
+ error.bridge = true;
1472
+ return error;
1473
+ }
1474
+ gated.forEach(function(entry) {
1475
+ var path = entry[0].split('.');
1476
+ var capability = entry[1];
1477
+ var awaited = entry[2] === true;
1478
+ var owner = path.length > 1 ? craft[path[0]] : craft;
1479
+ var name = path[path.length - 1];
1480
+ if (!owner || typeof owner[name] !== 'function') { return; }
1481
+ var served = owner[name];
1482
+ owner[name] = function() {
1483
+ if (craft.capabilities[capability]) { return served.apply(this, arguments); }
1484
+ if (awaited) { return Promise.reject(refusal(capability)); }
1485
+ console.warn('craft: ' + name + ' did nothing because ' + capability + ' is disabled');
1486
+ return undefined;
1487
+ };
1488
+ });
1489
+ })(window.craft);
1490
+
1491
+ // Links that arrive before anything is listening (#215, as iOS
1492
+ // does since #198).
1493
+ //
1494
+ // Native dispatches a link once this script has run, and on a cold
1495
+ // start that is the link the app was opened with. Only a craftReady
1496
+ // handler can have called onLink by then, since onLink is defined
1497
+ // by this very script. So such a link used to reach nobody, and a
1498
+ // page that subscribes later, rather than asking getInitialURL,
1499
+ // never learned how it was opened.
1500
+ //
1501
+ // Held here instead, and handed to the first subscriber on the
1502
+ // next turn, so an unsubscribe returned in the same tick still
1503
+ // applies. The launch link belongs to getInitialURL once the page
1504
+ // has called it, whether native has dispatched it yet or not, so a
1505
+ // page that does both in the same tick or in a craftReady handler,
1506
+ // in either order, gets it once. A page that asks getInitialURL
1507
+ // only later, after an await, should skip `initial` in onLink.
1508
+ //
1509
+ // The state lives on window, not in this function, because this
1510
+ // script can run twice in one document, and a second copy must
1511
+ // neither reset it nor buffer every link a second time.
1512
+ (function installDeepLinkReplay(craft) {
1513
+ var replay = window.__craftDeepLinkReplay;
1514
+ if (!replay) {
1515
+ replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
1516
+ window.addEventListener('craftDeepLink', function(e) {
1517
+ if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
1518
+ });
1519
+ }
1520
+ function claimed(detail) {
1521
+ return replay.initialClaimed && detail && detail.initial;
1522
+ }
1523
+ craft._subscribeDeepLinks = function(callback) {
1524
+ var active = true;
1525
+ var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
1526
+ window.addEventListener('craftDeepLink', listener);
1527
+ if (!replay.subscribed) {
1528
+ replay.subscribed = true;
1529
+ setTimeout(function() {
1530
+ var pending = replay.undelivered;
1531
+ replay.undelivered = [];
1532
+ if (!active) return;
1533
+ pending.forEach(function(detail) { callback(detail); });
1534
+ }, 0);
1535
+ }
1536
+ return function() {
1537
+ active = false;
1538
+ window.removeEventListener('craftDeepLink', listener);
1539
+ };
1540
+ };
1541
+ craft._claimInitialDeepLink = function() {
1542
+ replay.initialClaimed = true;
1543
+ replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
1544
+ };
1545
+ })(window.craft);
1546
+
1349
1547
  (function installCraftMobileContract(craft) {
1350
1548
  var legacyShare = craft.share.bind(craft);
1351
1549
  var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -1357,11 +1555,26 @@ class CraftBridge(
1357
1555
  getInfo: function() { return Promise.resolve().then(function() { return craft.getDeviceInfo(); }); },
1358
1556
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
1359
1557
  };
1558
+ // Haptics are feedback, so an app built without them has
1559
+ // nothing to play here rather than something to fail: `await
1560
+ // haptics.selection()` in the middle of a flow must not stop
1561
+ // the flow. Any other failure still rejects, and
1562
+ // craft.haptic() itself reports the refusal. The same split
1563
+ // iOS draws since #207, which Android could not draw until
1564
+ // craft.haptic() had an answer to give.
1565
+ function hapticFeedback(answer) {
1566
+ return answer.then(function() {}, function(error) {
1567
+ if (error && error.code === 'CAPABILITY_DISABLED') return;
1568
+ throw error;
1569
+ });
1570
+ }
1360
1571
  craft.haptics = {
1361
- impact: function(style) { return Promise.resolve().then(function() { craft.haptic(style || 'medium'); }); },
1362
- notification: function(type) { return Promise.resolve().then(function() { craft.haptic(type === 'error' ? 'heavy' : 'light'); }); },
1363
- selection: function() { return Promise.resolve().then(function() { craft.haptic('selection'); }); },
1364
- vibrate: function(pattern) { return Promise.resolve().then(function() { craft.vibrate(pattern || []); }); }
1572
+ impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
1573
+ notification: function(type) {
1574
+ return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
1575
+ },
1576
+ selection: function() { return hapticFeedback(craft.haptic('selection')); },
1577
+ vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
1365
1578
  };
1366
1579
  craft.camera = {
1367
1580
  takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
@@ -1471,25 +1684,47 @@ class CraftBridge(
1471
1684
 
1472
1685
  activity.runOnUiThread {
1473
1686
  if (closed) return@runOnUiThread
1474
- webView.evaluateJavascript(script) {
1687
+ // Read here rather than at entry: this is the navigation the
1688
+ // script is actually being injected into.
1689
+ val generation = navigationGeneration
1690
+ webView.evaluateJavascript(onlyOnce(script)) {
1475
1691
  if (closed) return@evaluateJavascript
1692
+ // A completion for a page that has since navigated away.
1693
+ // Marking ready now would flush into the new document, which
1694
+ // has no bridge yet, and the queues would be spent on it.
1695
+ if (generation != navigationGeneration) return@evaluateJavascript
1476
1696
  bridgeReady = true
1477
1697
  val events = pendingEvents.toList()
1478
1698
  pendingEvents.clear()
1479
1699
  events.forEach { (name, data) -> sendEvent(name, data) }
1700
+ // After the script, so the page's replay listener exists and
1701
+ // the native deliverer is installed.
1702
+ val links = pendingDeepLinks.toList()
1703
+ pendingDeepLinks.clear()
1704
+ links.forEach { (url, initial) -> dispatchDeepLink(url, initial) }
1480
1705
  }
1481
1706
  }
1482
1707
  }
1483
1708
 
1484
1709
  fun markBridgeLoading() {
1485
1710
  bridgeReady = false
1711
+ // A document is on its way out, so any injection still in flight is
1712
+ // for a page that no longer exists.
1713
+ navigationGeneration += 1
1486
1714
  }
1487
1715
 
1488
1716
  // ==================== Haptics ====================
1489
1717
 
1718
+ /**
1719
+ * Whether the device took the call, which is the answer the page awaits
1720
+ * since #219. False is "nothing played": haptics are off, or the vibrator
1721
+ * refused. It is never an error, because haptics decorate a flow rather
1722
+ * than carry it.
1723
+ */
1490
1724
  @JavascriptInterface
1491
- fun haptic(style: String) {
1492
- if (CraftNative.haptic(activity, style)) return
1725
+ fun haptic(style: String): Boolean {
1726
+ if (!hapticsEnabled && disabled("haptics", "haptic")) return false
1727
+ if (CraftNative.haptic(activity, style)) return true
1493
1728
 
1494
1729
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1495
1730
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -1515,113 +1750,224 @@ class CraftBridge(
1515
1750
  @Suppress("DEPRECATION")
1516
1751
  vibrator.vibrate(20)
1517
1752
  }
1753
+ return true
1518
1754
  }
1519
1755
 
1520
1756
  // ==================== Speech Recognition ====================
1521
1757
 
1758
+ /**
1759
+ * Whether the request was taken, which is what iOS answers too: the
1760
+ * outcome arrives as craftSpeech* events, not here (#207, #219).
1761
+ *
1762
+ * Asking for the microphone counts as taken. iOS starts recording from
1763
+ * inside its authorization callback, so Android does the same from
1764
+ * onRequestPermissionsResult below; before #219 the grant was dropped and
1765
+ * the page heard nothing at all, not even craftSpeechError.
1766
+ */
1522
1767
  @JavascriptInterface
1523
- fun startListening() {
1524
- if (CraftNative.startListening(activity)) return
1768
+ fun startListening(): Boolean {
1769
+ if (!speechEnabled && disabled("speechRecognition", "startListening")) return false
1770
+ if (CraftNative.startListening(activity)) return true
1525
1771
 
1526
1772
  if (!SpeechRecognizer.isRecognitionAvailable(activity)) {
1527
1773
  sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available"))
1528
- return
1774
+ return false
1529
1775
  }
1530
1776
 
1531
1777
  activity.runOnUiThread {
1532
1778
  if (closed) return@runOnUiThread
1533
1779
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
1534
1780
  != PackageManager.PERMISSION_GRANTED) {
1535
- requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1781
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_SPEECH)
1536
1782
  return@runOnUiThread
1537
1783
  }
1784
+ beginSpeechRecognition()
1785
+ }
1786
+ return true
1787
+ }
1538
1788
 
1539
- speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity)
1540
- speechRecognizer?.setRecognitionListener(object : RecognitionListener {
1541
- override fun onReadyForSpeech(params: Bundle?) {
1542
- isListening = true
1543
- sendEvent("craftSpeechStart", emptyMap())
1544
- haptic("light")
1545
- }
1546
-
1547
- override fun onBeginningOfSpeech() {}
1548
- override fun onRmsChanged(rmsdB: Float) {}
1549
- override fun onBufferReceived(buffer: ByteArray?) {}
1550
- override fun onEndOfSpeech() {}
1551
-
1552
- override fun onError(error: Int) {
1553
- isListening = false
1554
- val errorMsg = when (error) {
1555
- SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
1556
- SpeechRecognizer.ERROR_CLIENT -> "Client error"
1557
- SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions"
1558
- SpeechRecognizer.ERROR_NETWORK -> "Network error"
1559
- SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
1560
- SpeechRecognizer.ERROR_NO_MATCH -> "No match"
1561
- SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
1562
- SpeechRecognizer.ERROR_SERVER -> "Server error"
1563
- SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
1564
- else -> "Unknown error"
1565
- }
1566
- sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1567
- haptic("light")
1568
- }
1569
-
1570
- override fun onResults(results: Bundle?) {
1571
- isListening = false
1572
- val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1573
- val transcript = matches?.firstOrNull() ?: ""
1574
- sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true))
1575
- sendEvent("craftSpeechEnd", emptyMap())
1576
- haptic("light")
1577
- }
1789
+ /** The recogniser itself, on the main thread with RECORD_AUDIO granted. */
1790
+ private fun beginSpeechRecognition() {
1791
+ if (closed) return
1792
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity)
1793
+ speechRecognizer?.setRecognitionListener(object : RecognitionListener {
1794
+ override fun onReadyForSpeech(params: Bundle?) {
1795
+ isListening = true
1796
+ sendEvent("craftSpeechStart", emptyMap())
1797
+ haptic("light")
1798
+ }
1578
1799
 
1579
- override fun onPartialResults(partialResults: Bundle?) {
1580
- val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1581
- val transcript = matches?.firstOrNull() ?: ""
1582
- sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to false))
1800
+ override fun onBeginningOfSpeech() {}
1801
+ override fun onRmsChanged(rmsdB: Float) {}
1802
+ override fun onBufferReceived(buffer: ByteArray?) {}
1803
+ override fun onEndOfSpeech() {}
1804
+
1805
+ override fun onError(error: Int) {
1806
+ isListening = false
1807
+ val errorMsg = when (error) {
1808
+ SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
1809
+ SpeechRecognizer.ERROR_CLIENT -> "Client error"
1810
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions"
1811
+ SpeechRecognizer.ERROR_NETWORK -> "Network error"
1812
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
1813
+ SpeechRecognizer.ERROR_NO_MATCH -> "No match"
1814
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
1815
+ SpeechRecognizer.ERROR_SERVER -> "Server error"
1816
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
1817
+ else -> "Unknown error"
1583
1818
  }
1819
+ sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1820
+ haptic("light")
1821
+ }
1584
1822
 
1585
- override fun onEvent(eventType: Int, params: Bundle?) {}
1586
- })
1823
+ override fun onResults(results: Bundle?) {
1824
+ isListening = false
1825
+ val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1826
+ val transcript = matches?.firstOrNull() ?: ""
1827
+ sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true))
1828
+ sendEvent("craftSpeechEnd", emptyMap())
1829
+ haptic("light")
1830
+ }
1587
1831
 
1588
- val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
1589
- putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
1590
- putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
1591
- putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
1592
- putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
1832
+ override fun onPartialResults(partialResults: Bundle?) {
1833
+ val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1834
+ val transcript = matches?.firstOrNull() ?: ""
1835
+ sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to false))
1593
1836
  }
1594
1837
 
1595
- speechRecognizer?.startListening(intent)
1838
+ override fun onEvent(eventType: Int, params: Bundle?) {}
1839
+ })
1840
+
1841
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
1842
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
1843
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
1844
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
1845
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
1596
1846
  }
1847
+
1848
+ speechRecognizer?.startListening(intent)
1597
1849
  }
1598
1850
 
1851
+ /**
1852
+ * Whether the request was taken. Ungated, as on iOS: stopping something
1853
+ * an app was built without is a no-op, not a refusal, and a page tearing
1854
+ * a screen down should not have to know which build it is running on.
1855
+ */
1599
1856
  @JavascriptInterface
1600
- fun stopListening() {
1601
- if (CraftNative.stopListening(activity)) return
1857
+ fun stopListening(): Boolean {
1858
+ if (CraftNative.stopListening(activity)) return true
1602
1859
 
1603
1860
  activity.runOnUiThread {
1604
1861
  speechRecognizer?.stopListening()
1605
1862
  speechRecognizer?.destroy()
1606
1863
  speechRecognizer = null
1607
- isListening = false
1864
+ // Only when something was listening, so a page that stops twice,
1865
+ // or stops what never started, does not see a session end twice.
1866
+ // iOS sends this from stopSpeechRecognition for the same reason.
1867
+ if (isListening) {
1868
+ isListening = false
1869
+ sendEvent("craftSpeechEnd", emptyMap())
1870
+ }
1608
1871
  }
1872
+ return true
1609
1873
  }
1610
1874
 
1611
1875
  // ==================== Share ====================
1612
1876
 
1877
+ // Whether the person picked an app from the share menu that is open now.
1878
+ //
1879
+ // The chooser says so through the IntentSender below and says nothing at
1880
+ // all when it is dismissed, while its activity result comes back
1881
+ // RESULT_CANCELED either way. Neither signal answers on its own, so the
1882
+ // pick marks this and the result, which always arrives, settles the page's
1883
+ // promise with it. Everything that touches it runs on the main thread: the
1884
+ // launch is posted there, and receivers and results are delivered there.
1885
+ private var shareChosen = false
1886
+ private var shareReceiverRegistered = false
1887
+ private val shareChosenAction by lazy { "${activity.packageName}.CRAFT_SHARE_CHOSEN" }
1888
+ private val shareChosenReceiver = object : BroadcastReceiver() {
1889
+ override fun onReceive(context: Context, intent: Intent) {
1890
+ shareChosen = true
1891
+ }
1892
+ }
1893
+
1613
1894
  @JavascriptInterface
1614
1895
  fun share(text: String, title: String) {
1615
- if (CraftNative.share(activity, text, title)) return
1896
+ if (!shareEnabled) {
1897
+ rejectShare("share is disabled: this app was built without it")
1898
+ disabled("share", "share")
1899
+ return
1900
+ }
1901
+ // The same line iOS draws: an empty string is nothing to share, and a
1902
+ // chooser opened over nothing would offer apps a blank message.
1903
+ if (text.isEmpty()) {
1904
+ rejectShare("Nothing to share")
1905
+ return
1906
+ }
1616
1907
 
1617
- val intent = Intent(Intent.ACTION_SEND).apply {
1618
- type = "text/plain"
1619
- putExtra(Intent.EXTRA_TEXT, text)
1620
- if (title.isNotEmpty()) {
1621
- putExtra(Intent.EXTRA_SUBJECT, title)
1908
+ activity.runOnUiThread {
1909
+ if (closed) return@runOnUiThread
1910
+ shareChosen = false
1911
+
1912
+ val chosen = try {
1913
+ shareChosenSender()
1914
+ } catch (error: Exception) {
1915
+ rejectShare(error.message ?: "Unable to open the share menu")
1916
+ return@runOnUiThread
1917
+ }
1918
+
1919
+ // Zig launches the same chooser for the same result; the answer
1920
+ // still comes back through onActivityResult below.
1921
+ if (CraftNative.share(activity, text, title, chosen, REQUEST_SHARE)) return@runOnUiThread
1922
+
1923
+ val intent = Intent(Intent.ACTION_SEND).apply {
1924
+ type = "text/plain"
1925
+ putExtra(Intent.EXTRA_TEXT, text)
1926
+ if (title.isNotEmpty()) {
1927
+ putExtra(Intent.EXTRA_SUBJECT, title)
1928
+ }
1929
+ }
1930
+ try {
1931
+ activity.startActivityForResult(Intent.createChooser(intent, "Share", chosen), REQUEST_SHARE)
1932
+ } catch (error: Exception) {
1933
+ rejectShare(error.message ?: "Unable to open the share menu")
1622
1934
  }
1623
1935
  }
1624
- activity.startActivity(Intent.createChooser(intent, "Share"))
1936
+ }
1937
+
1938
+ // The IntentSender the chooser fires on a pick.
1939
+ //
1940
+ // Immutable: the chooser adds the chosen component to the intent it
1941
+ // fires, and this only needs to know that it fired. Explicit, by package,
1942
+ // so the broadcast reaches this app and nothing else, and so the
1943
+ // not-exported receiver accepts it.
1944
+ private fun shareChosenSender(): IntentSender {
1945
+ if (!shareReceiverRegistered) {
1946
+ ContextCompat.registerReceiver(
1947
+ activity,
1948
+ shareChosenReceiver,
1949
+ IntentFilter(shareChosenAction),
1950
+ ContextCompat.RECEIVER_NOT_EXPORTED
1951
+ )
1952
+ shareReceiverRegistered = true
1953
+ }
1954
+ val pick = Intent(shareChosenAction).setPackage(activity.packageName)
1955
+ return PendingIntent.getBroadcast(
1956
+ activity,
1957
+ REQUEST_SHARE,
1958
+ pick,
1959
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
1960
+ ).intentSender
1961
+ }
1962
+
1963
+ private fun handleShareResult() {
1964
+ val chosen = shareChosen
1965
+ shareChosen = false
1966
+ evaluatePromiseJavascript("window._craftShareResolve && window._craftShareResolve($chosen)")
1967
+ }
1968
+
1969
+ private fun rejectShare(message: String) {
1970
+ evaluatePromiseJavascript("window._craftShareReject && window._craftShareReject(${jsQuote(message)})")
1625
1971
  }
1626
1972
 
1627
1973
  // ==================== Camera & Gallery ====================
@@ -2004,6 +2350,12 @@ class CraftBridge(
2004
2350
 
2005
2351
  fun onRequestPermissionsResult(requestCode: Int): Boolean {
2006
2352
  if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END
2353
+ // startListening asked for the microphone and is waiting on this.
2354
+ if (requestCode == REQUEST_SPEECH) {
2355
+ if (isPermissionGranted(Manifest.permission.RECORD_AUDIO)) beginSpeechRecognition()
2356
+ else sendEvent("craftSpeechError", mapOf("error" to "Microphone permission denied"))
2357
+ return true
2358
+ }
2007
2359
  val pending = pendingPermissionRequests.remove(requestCode) ?: return false
2008
2360
  // Re-read package state instead of interpreting the parallel callback
2009
2361
  // arrays. In particular, an approximate-location choice returns a
@@ -2366,10 +2718,49 @@ class CraftBridge(
2366
2718
  activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode
2367
2719
  }
2368
2720
  } catch (e: Exception) { 0 })
2369
- put("isEmulator", Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("emulator"))
2721
+ put("isEmulator", isEmulator())
2370
2722
  }.toString()
2371
2723
  }
2372
2724
 
2725
+ /**
2726
+ * Whether this is an emulator rather than a phone.
2727
+ *
2728
+ * This used to be `FINGERPRINT.contains("generic") ||
2729
+ * FINGERPRINT.contains("emulator")`, and it answered false on the AVDs
2730
+ * anyone actually runs. A current Google APIs image fingerprints as
2731
+ *
2732
+ * google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/...:user/release-keys
2733
+ *
2734
+ * which contains neither word. The mobile E2E suite caught it on an API 34
2735
+ * x86_64 emulator, reporting `isEmulator is false` from inside one.
2736
+ *
2737
+ * `HARDWARE` is the reliable signal and leads here: the AVD's virtual board
2738
+ * is `goldfish` on the old QEMU pipeline and `ranchu` on the current one,
2739
+ * and has been one of those two for over a decade. The rest are kept as
2740
+ * secondary evidence for images that report something else - Genymotion,
2741
+ * and the older `generic`/`sdk` products.
2742
+ *
2743
+ * Note this is a hint, not a security boundary. Anything that can rewrite
2744
+ * these properties can defeat it, and nothing here should be treated as
2745
+ * proof of where the code is running.
2746
+ */
2747
+ private fun isEmulator(): Boolean {
2748
+ val hardware = Build.HARDWARE.lowercase()
2749
+ if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true
2750
+
2751
+ val fingerprint = Build.FINGERPRINT.lowercase()
2752
+ if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true
2753
+ if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true
2754
+
2755
+ val product = Build.PRODUCT.lowercase()
2756
+ if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true
2757
+
2758
+ val model = Build.MODEL.lowercase()
2759
+ if (model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for")) return true
2760
+
2761
+ return Build.MANUFACTURER.contains("Genymotion") || Build.BRAND.startsWith("generic")
2762
+ }
2763
+
2373
2764
  // ==================== Network Status ====================
2374
2765
 
2375
2766
  @JavascriptInterface
@@ -2543,9 +2934,11 @@ class CraftBridge(
2543
2934
 
2544
2935
  // ==================== Vibration Pattern ====================
2545
2936
 
2937
+ /** Whether the pattern played, the same answer haptic() gives (#219). */
2546
2938
  @JavascriptInterface
2547
- fun vibrate(patternJson: String) {
2548
- if (CraftNative.vibrate(activity, patternJson)) return
2939
+ fun vibrate(patternJson: String): Boolean {
2940
+ if (!hapticsEnabled && disabled("haptics", "vibrate")) return false
2941
+ if (CraftNative.vibrate(activity, patternJson)) return true
2549
2942
 
2550
2943
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2551
2944
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -2566,8 +2959,10 @@ class CraftBridge(
2566
2959
  @Suppress("DEPRECATION")
2567
2960
  vibrator.vibrate(timings, -1)
2568
2961
  }
2962
+ return true
2569
2963
  } catch (e: Exception) {
2570
2964
  log("Vibration error: ${e.message}")
2965
+ return false
2571
2966
  }
2572
2967
  }
2573
2968
 
@@ -3144,6 +3539,7 @@ class CraftBridge(
3144
3539
 
3145
3540
  @JavascriptInterface
3146
3541
  fun setKeepAwake(enabled: Boolean): Boolean {
3542
+ if (!keepAwakeEnabled && disabled("keepAwake", "setKeepAwake")) return false
3147
3543
  // Zig queues the same window call on the same looper. isKeepingAwake
3148
3544
  // below is written here and read nowhere, so the Kotlin body not
3149
3545
  // running leaves nothing stale — unlike the flashlight pair.
@@ -3190,18 +3586,6 @@ class CraftBridge(
3190
3586
  return true
3191
3587
  }
3192
3588
 
3193
- // ==================== Deep Links ====================
3194
-
3195
- fun handleDeepLink(uri: android.net.Uri) {
3196
- sendEvent("craftDeepLink", mapOf(
3197
- "url" to uri.toString(),
3198
- "scheme" to (uri.scheme ?: ""),
3199
- "host" to (uri.host ?: ""),
3200
- "path" to (uri.path ?: ""),
3201
- "query" to (uri.query ?: "")
3202
- ))
3203
- }
3204
-
3205
3589
  // ==================== QR/Barcode Scanner ====================
3206
3590
 
3207
3591
  @JavascriptInterface
@@ -3659,7 +4043,7 @@ class CraftBridge(
3659
4043
  fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
3660
4044
  if (closed) return requestCode == REQUEST_CAMERA || requestCode == REQUEST_GALLERY ||
3661
4045
  requestCode == REQUEST_FILE_PICKER || requestCode == REQUEST_VIDEO ||
3662
- requestCode == REQUEST_PICK_CONTACT
4046
+ requestCode == REQUEST_PICK_CONTACT || requestCode == REQUEST_SHARE
3663
4047
 
3664
4048
  return when (requestCode) {
3665
4049
  REQUEST_CAMERA, REQUEST_GALLERY -> {
@@ -3678,6 +4062,10 @@ class CraftBridge(
3678
4062
  handleContactPickerResult(resultCode, data)
3679
4063
  true
3680
4064
  }
4065
+ REQUEST_SHARE -> {
4066
+ handleShareResult()
4067
+ true
4068
+ }
3681
4069
  else -> healthConnect.onActivityResult(requestCode, resultCode, data)
3682
4070
  }
3683
4071
  }
@@ -3697,6 +4085,11 @@ class CraftBridge(
3697
4085
  CraftNative.close(activity)
3698
4086
  healthConnect.close()
3699
4087
 
4088
+ if (shareReceiverRegistered) {
4089
+ runCatching { activity.unregisterReceiver(shareChosenReceiver) }
4090
+ shareReceiverRegistered = false
4091
+ }
4092
+
3700
4093
  runCatching { speechRecognizer?.cancel() }
3701
4094
  runCatching { speechRecognizer?.destroy() }
3702
4095
  speechRecognizer = null
@@ -3756,6 +4149,7 @@ class CraftBridge(
3756
4149
  isKeepingAwake = false
3757
4150
  pendingPermissionRequests.clear()
3758
4151
  pendingEvents.clear()
4152
+ pendingDeepLinks.clear()
3759
4153
  }
3760
4154
 
3761
4155
  // ==================== Screen Capture ====================
@@ -4529,13 +4923,44 @@ class CraftBridge(
4529
4923
  }
4530
4924
  }
4531
4925
 
4532
- fun dispatchDeepLink(url: String) {
4533
- if (nativeDeepLinks && CraftNative.dispatchDeepLink(url)) return
4534
-
4535
- // Store as initial URL if this is the first one
4536
- if (initialURL == null) {
4537
- initialURL = url
4926
+ /**
4927
+ * A link the activity received. The first one before any page was ready
4928
+ * launched the app: it becomes getInitialURL's answer and is dispatched
4929
+ * with `initial: true`. Every later link is `initial: false` and leaves
4930
+ * getInitialURL alone, as on iOS.
4931
+ */
4932
+ fun receiveDeepLink(url: String) {
4933
+ val initial = !hasBeenReady && !initialAssigned
4934
+ if (initial) {
4935
+ initialAssigned = true
4936
+ launchURL = url
4937
+ setInitialURL(url)
4538
4938
  }
4939
+ if (bridgeReady) dispatchDeepLink(url, false) else pendingDeepLinks.add(url to initial)
4940
+ }
4941
+
4942
+ fun saveDeepLinks(outState: Bundle) {
4943
+ outState.putBoolean(STATE_LAUNCH_ASSIGNED, initialAssigned)
4944
+ outState.putBoolean(STATE_PAGE_WAS_READY, hasBeenReady)
4945
+ outState.putString(STATE_LAUNCH_URL, launchURL)
4946
+ }
4947
+
4948
+ /**
4949
+ * A recreated activity loads a new WebView and a fresh document, so the
4950
+ * launch link is queued again for that document's first subscriber, the
4951
+ * way it was for the first one; a page that called getInitialURL claims
4952
+ * it as before. Links that arrive now are never the launch link.
4953
+ */
4954
+ fun restoreDeepLinks(savedState: Bundle) {
4955
+ initialAssigned = savedState.getBoolean(STATE_LAUNCH_ASSIGNED, true)
4956
+ hasBeenReady = savedState.getBoolean(STATE_PAGE_WAS_READY, true)
4957
+ launchURL = savedState.getString(STATE_LAUNCH_URL)
4958
+ setInitialURL(launchURL)
4959
+ launchURL?.let { pendingDeepLinks.add(it to true) }
4960
+ }
4961
+
4962
+ private fun dispatchDeepLink(url: String, initial: Boolean) {
4963
+ if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return
4539
4964
 
4540
4965
  val uri = Uri.parse(url)
4541
4966
  val json = JSONObject().apply {
@@ -4550,6 +4975,7 @@ class CraftBridge(
4550
4975
  queryParams.put(name, uri.getQueryParameter(name) ?: "")
4551
4976
  }
4552
4977
  put("queryParams", queryParams)
4978
+ put("initial", initial)
4553
4979
  }
4554
4980
 
4555
4981
  evaluateJavascriptUnlessClosed(
@@ -4620,6 +5046,9 @@ class CraftBridge(
4620
5046
  }
4621
5047
 
4622
5048
  companion object {
5049
+ private const val STATE_LAUNCH_ASSIGNED = "craft.deepLinks.launchAssigned"
5050
+ private const val STATE_PAGE_WAS_READY = "craft.deepLinks.pageWasReady"
5051
+ private const val STATE_LAUNCH_URL = "craft.deepLinks.launchURL"
4623
5052
  private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$")
4624
5053
  const val REQUEST_CAMERA = 1001
4625
5054
  const val REQUEST_GALLERY = 1002
@@ -4631,6 +5060,8 @@ class CraftBridge(
4631
5060
  const val REQUEST_VIDEO = 1008
4632
5061
  const val REQUEST_BLUETOOTH = 1009
4633
5062
  const val REQUEST_PICK_CONTACT = 1010
5063
+ const val REQUEST_SHARE = 1011
5064
+ const val REQUEST_SPEECH = 1012
4634
5065
  private const val PERMISSION_REQUEST_START = 2000
4635
5066
  private const val PERMISSION_REQUEST_END = 2999
4636
5067
  private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L