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.
- package/README.md +17 -0
- package/dist/android/src/index.d.ts +39 -1
- package/dist/android/src/index.js +55 -4
- package/dist/android/templates/CraftBridge.kt.template +534 -103
- package/dist/android/templates/CraftNative.kt.template +38 -13
- package/dist/android/templates/MainActivity.kt.template +98 -10
- package/dist/api/mobile.d.ts +44 -2
- package/dist/binary-resolver.d.ts +4 -0
- package/dist/cli.js +65 -9
- package/dist/index.cjs +89 -54
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +90 -55
- package/dist/ios/src/index.d.ts +1 -1
- package/dist/ios/src/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +681 -57
- package/dist/mobile.js +40 -9
- package/dist/sdk-version.d.ts +1 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1362
|
-
notification: function(type) {
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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),
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
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
|