craft-native 0.0.92 → 0.0.95
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 +17 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +8 -4
- package/dist/android/templates/CraftBridge.kt.template +271 -72
- package/dist/android/templates/CraftNative.kt.template +12 -6
- package/dist/android/templates/MainActivity.kt.template +86 -9
- package/dist/api/mobile.d.ts +32 -0
- package/dist/binary-resolver.d.ts +4 -0
- package/dist/cli.js +17 -8
- package/dist/index.cjs +77 -49
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +78 -50
- package/dist/ios/templates/CraftApp.swift +450 -27
- package/dist/mobile.js +24 -1
- package/dist/sdk-version.d.ts +1 -0
- package/package.json +2 -2
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:
|
|
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
|
|
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
|
|
1439
|
-
notification: function(type) {
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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),
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
216
|
-
* WebView exists
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
-
|
|
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?
|