craft-native 0.0.92 → 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.
package/README.md CHANGED
@@ -453,6 +453,23 @@ See [benchmarks](../../benchmarks) for detailed performance comparisons.
453
453
  - **Zero dependencies** - Pure Node.js APIs only
454
454
  - **Better DX** - Hot reload and dev tools built-in
455
455
 
456
+ ## Native bridge error codes
457
+
458
+ The mobile bridge declarations in `types/craft.d.ts` distinguish canonical
459
+ `NativeBridgeErrorCode` values from the broader compatible `CraftErrorCode` union.
460
+ The native wire vocabulary comes from `packages/zig/src/bridge_error.zig`; SDK
461
+ parity tests fail if the declaration drifts. It includes `INVALID_PARAMETER`,
462
+ `PLATFORM_NOT_SUPPORTED`, `NATIVE_CALL_FAILED`, `WEBVIEW_HANDLE_NOT_SET`, `BUSY`,
463
+ and `CAPABILITY_DISABLED`.
464
+
465
+ Existing mobile/shim names such as `INVALID_PARAMS` and `NOT_AVAILABLE` remain
466
+ accepted by `CraftErrorCode` for source compatibility. They are not aliases that
467
+ the SDK rewrites on the wire. Applications supporting multiple host versions
468
+ should handle the codes those hosts actually return; new native error handling
469
+ should use the canonical spellings. A capability disabled by app configuration
470
+ is distinct from a user denying permission, and `BUSY` is distinct from a failed
471
+ native call.
472
+
456
473
  ## Requirements
457
474
 
458
475
  - Bun >= 1.0.0 (for development)
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Generates native Android apps from web content using WebView.
5
5
  */
6
+ /** Oldest Android API level supported by the generated Kotlin runtime. */
7
+ export declare const ANDROID_MIN_SDK = 26;
6
8
  export interface CraftAndroidConfig {
7
9
  appName: string;
8
10
  packageName: string;
@@ -28,6 +30,7 @@ export interface CraftAndroidConfig {
28
30
  googleServicesFile?: string;
29
31
  devServerURL?: string;
30
32
  hasBundledFallback?: boolean;
33
+ /** Android API level, at least ANDROID_MIN_SDK (26). */
31
34
  minSdk?: number;
32
35
  compileSdk?: number;
33
36
  targetSdk?: number;
@@ -110,6 +110,7 @@ var KOTLIN_KEYWORDS = new Set([
110
110
  "when",
111
111
  "while"
112
112
  ]);
113
+ var ANDROID_MIN_SDK = 26;
113
114
  var DEFAULT_CONFIG = {
114
115
  version: "1.0.0",
115
116
  versionCode: 1,
@@ -129,7 +130,7 @@ var DEFAULT_CONFIG = {
129
130
  enableDeepLinks: false,
130
131
  urlSchemes: [],
131
132
  trustedOrigins: [],
132
- minSdk: 26,
133
+ minSdk: ANDROID_MIN_SDK,
133
134
  compileSdk: 36,
134
135
  targetSdk: 35
135
136
  };
@@ -228,6 +229,9 @@ function validateAndroidConfig(config) {
228
229
  throw new Error(`Android ${name} must be a positive integer`);
229
230
  }
230
231
  }
232
+ if (Number(config.minSdk) < ANDROID_MIN_SDK) {
233
+ throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`);
234
+ }
231
235
  if (Number(config.minSdk) > Number(config.targetSdk)) {
232
236
  throw new Error("Android minSdk must not exceed targetSdk");
233
237
  }
@@ -366,7 +370,6 @@ async function init(options) {
366
370
  if (config.enableBackgroundLocation)
367
371
  config.enableGeolocation = true;
368
372
  if (config.enableHealthConnect) {
369
- config.minSdk = Math.max(config.minSdk ?? 26, 26);
370
373
  config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
371
374
  }
372
375
  normalizeAndroidNetworkConfig(config);
@@ -450,7 +453,7 @@ async function init(options) {
450
453
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
451
454
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
452
455
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
453
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk || 24)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
456
+ const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
454
457
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
455
458
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
456
459
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
@@ -692,5 +695,6 @@ export {
692
695
  open,
693
696
  installRuntime,
694
697
  init,
695
- build
698
+ build,
699
+ ANDROID_MIN_SDK
696
700
  };
@@ -219,8 +219,35 @@ class CraftBridge(
219
219
  private val mainExecutor: Executor = ContextCompat.getMainExecutor(activity)
220
220
  @Volatile
221
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
222
235
  private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
223
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
+
224
251
  // Deep links (#215), on the UI thread only, the way iOS's DeepLinkManager
225
252
  // holds them: a link that arrives before the first page has its bridge is
226
253
  // the launch link, and every link waits for an injected bridge.
@@ -313,6 +340,24 @@ class CraftBridge(
313
340
  */
314
341
  private fun jsQuote(value: Any?): String = JSONObject.quote(value.toString())
315
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
+
316
361
  fun injectBridge() {
317
362
  // Before the script, so a native callback arriving while the page is
318
363
  // still loading has somewhere to go rather than being counted as
@@ -367,17 +412,29 @@ class CraftBridge(
367
412
  screenCapture: false
368
413
  },
369
414
 
370
- // 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.
371
428
  haptic: function(style) {
372
- CraftAndroid.haptic(style || 'medium');
429
+ return Promise.resolve().then(function() { return CraftAndroid.haptic(style || 'medium') === true; });
373
430
  },
374
431
 
375
432
  // Speech recognition
376
433
  startListening: function() {
377
- CraftAndroid.startListening();
434
+ return Promise.resolve().then(function() { return CraftAndroid.startListening() === true; });
378
435
  },
379
436
  stopListening: function() {
380
- CraftAndroid.stopListening();
437
+ return Promise.resolve().then(function() { return CraftAndroid.stopListening() === true; });
381
438
  },
382
439
 
383
440
  // Share. Resolves true when the person picks an app, false
@@ -512,7 +569,7 @@ class CraftBridge(
512
569
 
513
570
  // Vibration Pattern
514
571
  vibrate: function(pattern) {
515
- CraftAndroid.vibrate(JSON.stringify(pattern));
572
+ return Promise.resolve().then(function() { return CraftAndroid.vibrate(JSON.stringify(pattern)) === true; });
516
573
  },
517
574
 
518
575
  // App State
@@ -1367,6 +1424,70 @@ class CraftBridge(
1367
1424
  }
1368
1425
  };
1369
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
+
1370
1491
  // Links that arrive before anything is listening (#215, as iOS
1371
1492
  // does since #198).
1372
1493
  //
@@ -1434,11 +1555,26 @@ class CraftBridge(
1434
1555
  getInfo: function() { return Promise.resolve().then(function() { return craft.getDeviceInfo(); }); },
1435
1556
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
1436
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
+ }
1437
1571
  craft.haptics = {
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 || []); }); }
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 || [])); }
1442
1578
  };
1443
1579
  craft.camera = {
1444
1580
  takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
@@ -1548,8 +1684,15 @@ class CraftBridge(
1548
1684
 
1549
1685
  activity.runOnUiThread {
1550
1686
  if (closed) return@runOnUiThread
1551
- 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)) {
1552
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
1553
1696
  bridgeReady = true
1554
1697
  val events = pendingEvents.toList()
1555
1698
  pendingEvents.clear()
@@ -1565,13 +1708,23 @@ class CraftBridge(
1565
1708
 
1566
1709
  fun markBridgeLoading() {
1567
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
1568
1714
  }
1569
1715
 
1570
1716
  // ==================== Haptics ====================
1571
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
+ */
1572
1724
  @JavascriptInterface
1573
- fun haptic(style: String) {
1574
- 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
1575
1728
 
1576
1729
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1577
1730
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -1597,97 +1750,126 @@ class CraftBridge(
1597
1750
  @Suppress("DEPRECATION")
1598
1751
  vibrator.vibrate(20)
1599
1752
  }
1753
+ return true
1600
1754
  }
1601
1755
 
1602
1756
  // ==================== Speech Recognition ====================
1603
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
+ */
1604
1767
  @JavascriptInterface
1605
- fun startListening() {
1606
- if (CraftNative.startListening(activity)) return
1768
+ fun startListening(): Boolean {
1769
+ if (!speechEnabled && disabled("speechRecognition", "startListening")) return false
1770
+ if (CraftNative.startListening(activity)) return true
1607
1771
 
1608
1772
  if (!SpeechRecognizer.isRecognitionAvailable(activity)) {
1609
1773
  sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available"))
1610
- return
1774
+ return false
1611
1775
  }
1612
1776
 
1613
1777
  activity.runOnUiThread {
1614
1778
  if (closed) return@runOnUiThread
1615
1779
  if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
1616
1780
  != PackageManager.PERMISSION_GRANTED) {
1617
- requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1781
+ requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_SPEECH)
1618
1782
  return@runOnUiThread
1619
1783
  }
1784
+ beginSpeechRecognition()
1785
+ }
1786
+ return true
1787
+ }
1620
1788
 
1621
- speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity)
1622
- speechRecognizer?.setRecognitionListener(object : RecognitionListener {
1623
- override fun onReadyForSpeech(params: Bundle?) {
1624
- isListening = true
1625
- sendEvent("craftSpeechStart", emptyMap())
1626
- haptic("light")
1627
- }
1628
-
1629
- override fun onBeginningOfSpeech() {}
1630
- override fun onRmsChanged(rmsdB: Float) {}
1631
- override fun onBufferReceived(buffer: ByteArray?) {}
1632
- override fun onEndOfSpeech() {}
1633
-
1634
- override fun onError(error: Int) {
1635
- isListening = false
1636
- val errorMsg = when (error) {
1637
- SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
1638
- SpeechRecognizer.ERROR_CLIENT -> "Client error"
1639
- SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions"
1640
- SpeechRecognizer.ERROR_NETWORK -> "Network error"
1641
- SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
1642
- SpeechRecognizer.ERROR_NO_MATCH -> "No match"
1643
- SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
1644
- SpeechRecognizer.ERROR_SERVER -> "Server error"
1645
- SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
1646
- else -> "Unknown error"
1647
- }
1648
- sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1649
- haptic("light")
1650
- }
1651
-
1652
- override fun onResults(results: Bundle?) {
1653
- isListening = false
1654
- val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1655
- val transcript = matches?.firstOrNull() ?: ""
1656
- sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true))
1657
- sendEvent("craftSpeechEnd", emptyMap())
1658
- haptic("light")
1659
- }
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
+ }
1660
1799
 
1661
- override fun onPartialResults(partialResults: Bundle?) {
1662
- val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1663
- val transcript = matches?.firstOrNull() ?: ""
1664
- 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"
1665
1818
  }
1819
+ sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1820
+ haptic("light")
1821
+ }
1666
1822
 
1667
- override fun onEvent(eventType: Int, params: Bundle?) {}
1668
- })
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
+ }
1669
1831
 
1670
- val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
1671
- putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
1672
- putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
1673
- putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
1674
- 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))
1675
1836
  }
1676
1837
 
1677
- 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)
1678
1846
  }
1847
+
1848
+ speechRecognizer?.startListening(intent)
1679
1849
  }
1680
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
+ */
1681
1856
  @JavascriptInterface
1682
- fun stopListening() {
1683
- if (CraftNative.stopListening(activity)) return
1857
+ fun stopListening(): Boolean {
1858
+ if (CraftNative.stopListening(activity)) return true
1684
1859
 
1685
1860
  activity.runOnUiThread {
1686
1861
  speechRecognizer?.stopListening()
1687
1862
  speechRecognizer?.destroy()
1688
1863
  speechRecognizer = null
1689
- 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
+ }
1690
1871
  }
1872
+ return true
1691
1873
  }
1692
1874
 
1693
1875
  // ==================== Share ====================
@@ -1711,6 +1893,11 @@ class CraftBridge(
1711
1893
 
1712
1894
  @JavascriptInterface
1713
1895
  fun share(text: String, title: String) {
1896
+ if (!shareEnabled) {
1897
+ rejectShare("share is disabled: this app was built without it")
1898
+ disabled("share", "share")
1899
+ return
1900
+ }
1714
1901
  // The same line iOS draws: an empty string is nothing to share, and a
1715
1902
  // chooser opened over nothing would offer apps a blank message.
1716
1903
  if (text.isEmpty()) {
@@ -2163,6 +2350,12 @@ class CraftBridge(
2163
2350
 
2164
2351
  fun onRequestPermissionsResult(requestCode: Int): Boolean {
2165
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
+ }
2166
2359
  val pending = pendingPermissionRequests.remove(requestCode) ?: return false
2167
2360
  // Re-read package state instead of interpreting the parallel callback
2168
2361
  // arrays. In particular, an approximate-location choice returns a
@@ -2741,9 +2934,11 @@ class CraftBridge(
2741
2934
 
2742
2935
  // ==================== Vibration Pattern ====================
2743
2936
 
2937
+ /** Whether the pattern played, the same answer haptic() gives (#219). */
2744
2938
  @JavascriptInterface
2745
- fun vibrate(patternJson: String) {
2746
- 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
2747
2942
 
2748
2943
  val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2749
2944
  val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -2764,8 +2959,10 @@ class CraftBridge(
2764
2959
  @Suppress("DEPRECATION")
2765
2960
  vibrator.vibrate(timings, -1)
2766
2961
  }
2962
+ return true
2767
2963
  } catch (e: Exception) {
2768
2964
  log("Vibration error: ${e.message}")
2965
+ return false
2769
2966
  }
2770
2967
  }
2771
2968
 
@@ -3342,6 +3539,7 @@ class CraftBridge(
3342
3539
 
3343
3540
  @JavascriptInterface
3344
3541
  fun setKeepAwake(enabled: Boolean): Boolean {
3542
+ if (!keepAwakeEnabled && disabled("keepAwake", "setKeepAwake")) return false
3345
3543
  // Zig queues the same window call on the same looper. isKeepingAwake
3346
3544
  // below is written here and read nowhere, so the Kotlin body not
3347
3545
  // running leaves nothing stale — unlike the flashlight pair.
@@ -4863,6 +5061,7 @@ class CraftBridge(
4863
5061
  const val REQUEST_BLUETOOTH = 1009
4864
5062
  const val REQUEST_PICK_CONTACT = 1010
4865
5063
  const val REQUEST_SHARE = 1011
5064
+ const val REQUEST_SPEECH = 1012
4866
5065
  private const val PERMISSION_REQUEST_START = 2000
4867
5066
  private const val PERMISSION_REQUEST_END = 2999
4868
5067
  private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
@@ -212,14 +212,20 @@ object CraftNative {
212
212
  /**
213
213
  * Run `script` in the page. Called from Zig, on any thread.
214
214
  *
215
- * Silent when no deliverer is installed — which is every moment before the
216
- * WebView exists, and after it is torn down. A throw here would cross back
217
- * into Zig as a pending exception on a thread that has nowhere to report
218
- * it, and the caller already counts what it could not deliver.
215
+ * Returns whether a deliverer took it. False is every moment before the
216
+ * WebView exists and after it is torn down, and it used to be
217
+ * indistinguishable from a delivery: the script was dropped, Zig's caller
218
+ * reported success, and the page waited on a promise nothing would settle
219
+ * (#231). Zig counts a false, and says so once.
220
+ *
221
+ * Still no throw. A throw here would cross back into Zig as a pending
222
+ * exception on a thread with nowhere to report it; false says the same
223
+ * thing and the caller can act on it.
219
224
  */
220
225
  @JvmStatic
221
- fun deliver(script: String) {
222
- runCatching { deliverer?.invoke(script) }
226
+ fun deliver(script: String): Boolean {
227
+ val target = deliverer ?: return false
228
+ return runCatching { target(script); true }.getOrDefault(false)
223
229
  }
224
230
 
225
231
  private external fun nativeGetDeviceInfo(activity: Activity): String?