@craft-native/android 0.0.91 → 0.0.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -611,6 +611,20 @@ For automated uploads, integrate with fastlane:
|
|
|
611
611
|
fastlane supply --aab ./android/app/build/outputs/bundle/release/app-release.aab
|
|
612
612
|
```
|
|
613
613
|
|
|
614
|
+
### Native crash symbols
|
|
615
|
+
|
|
616
|
+
`zig build build-android-all` strips the DWARF out of each release
|
|
617
|
+
`libcraft.so` before the generator copies it into `jniLibs`. With it, the
|
|
618
|
+
library was about 5.5 MB per ABI in every APK. The symbol table stays, so a
|
|
619
|
+
native crash tombstone still names functions. The DWARF goes to
|
|
620
|
+
`zig-out/android-symbols/<abi>/libcraft.so.debug`, linked from the library by
|
|
621
|
+
`.gnu_debuglink`.
|
|
622
|
+
|
|
623
|
+
To symbolicate a crash, point `ndk-stack -sym` at that directory. To have Play
|
|
624
|
+
Console symbolicate for you, upload the `.debug` files as the release's native
|
|
625
|
+
debug symbols. A `-Doptimize=Debug` build keeps the debug info in the library
|
|
626
|
+
itself.
|
|
627
|
+
|
|
614
628
|
## License
|
|
615
629
|
|
|
616
630
|
MIT
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -218,6 +221,14 @@ class CraftBridge(
|
|
|
218
221
|
private var bridgeReady = false
|
|
219
222
|
private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
|
|
220
223
|
|
|
224
|
+
// Deep links (#215), on the UI thread only, the way iOS's DeepLinkManager
|
|
225
|
+
// holds them: a link that arrives before the first page has its bridge is
|
|
226
|
+
// the launch link, and every link waits for an injected bridge.
|
|
227
|
+
private var hasBeenReady = false
|
|
228
|
+
private var initialAssigned = false
|
|
229
|
+
private var launchURL: String? = null
|
|
230
|
+
private val pendingDeepLinks = mutableListOf<Pair<String, Boolean>>()
|
|
231
|
+
|
|
221
232
|
// Location tracking
|
|
222
233
|
private var fusedLocationClient: FusedLocationProviderClient? = null
|
|
223
234
|
private val locationCallbacks = mutableMapOf<Int, LocationCallback>()
|
|
@@ -307,6 +318,10 @@ class CraftBridge(
|
|
|
307
318
|
// still loading has somewhere to go rather than being counted as
|
|
308
319
|
// dropped.
|
|
309
320
|
installNativeDeliverer()
|
|
321
|
+
// A page is about to exist, so no later link launched the app, even
|
|
322
|
+
// one that arrives before this script's completion runs. iOS marks
|
|
323
|
+
// the same moment.
|
|
324
|
+
hasBeenReady = true
|
|
310
325
|
|
|
311
326
|
val script = """
|
|
312
327
|
{{PROMISE_RUNTIME}}
|
|
@@ -365,9 +380,14 @@ class CraftBridge(
|
|
|
365
380
|
CraftAndroid.stopListening();
|
|
366
381
|
},
|
|
367
382
|
|
|
368
|
-
// Share
|
|
383
|
+
// Share. Resolves true when the person picks an app, false
|
|
384
|
+
// when they dismiss the menu, and rejects when there is
|
|
385
|
+
// nothing to share or the menu cannot open. No timeout: the
|
|
386
|
+
// menu waits on a person, not on the device.
|
|
369
387
|
share: function(text, title) {
|
|
370
|
-
|
|
388
|
+
return window.__craftPromise('share', '_craftShareResolve', '_craftShareReject', function() {
|
|
389
|
+
CraftAndroid.share(text == null ? '' : String(text), title == null ? '' : String(title));
|
|
390
|
+
});
|
|
371
391
|
},
|
|
372
392
|
|
|
373
393
|
// Camera
|
|
@@ -590,7 +610,7 @@ class CraftBridge(
|
|
|
590
610
|
|
|
591
611
|
// Deep Links
|
|
592
612
|
onDeepLink: function(callback) {
|
|
593
|
-
window.
|
|
613
|
+
return window.craft._subscribeDeepLinks(callback);
|
|
594
614
|
},
|
|
595
615
|
|
|
596
616
|
// QR/Barcode Scanner
|
|
@@ -991,12 +1011,13 @@ class CraftBridge(
|
|
|
991
1011
|
// Deep Links
|
|
992
1012
|
deepLinks: {
|
|
993
1013
|
getInitialURL: function() {
|
|
1014
|
+
window.craft._claimInitialDeepLink();
|
|
994
1015
|
return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() {
|
|
995
1016
|
CraftAndroid.getInitialURL();
|
|
996
1017
|
});
|
|
997
1018
|
},
|
|
998
1019
|
onLink: function(callback) {
|
|
999
|
-
window.
|
|
1020
|
+
return window.craft._subscribeDeepLinks(callback);
|
|
1000
1021
|
}
|
|
1001
1022
|
},
|
|
1002
1023
|
|
|
@@ -1346,6 +1367,62 @@ class CraftBridge(
|
|
|
1346
1367
|
}
|
|
1347
1368
|
};
|
|
1348
1369
|
|
|
1370
|
+
// Links that arrive before anything is listening (#215, as iOS
|
|
1371
|
+
// does since #198).
|
|
1372
|
+
//
|
|
1373
|
+
// Native dispatches a link once this script has run, and on a cold
|
|
1374
|
+
// start that is the link the app was opened with. Only a craftReady
|
|
1375
|
+
// handler can have called onLink by then, since onLink is defined
|
|
1376
|
+
// by this very script. So such a link used to reach nobody, and a
|
|
1377
|
+
// page that subscribes later, rather than asking getInitialURL,
|
|
1378
|
+
// never learned how it was opened.
|
|
1379
|
+
//
|
|
1380
|
+
// Held here instead, and handed to the first subscriber on the
|
|
1381
|
+
// next turn, so an unsubscribe returned in the same tick still
|
|
1382
|
+
// applies. The launch link belongs to getInitialURL once the page
|
|
1383
|
+
// has called it, whether native has dispatched it yet or not, so a
|
|
1384
|
+
// page that does both in the same tick or in a craftReady handler,
|
|
1385
|
+
// in either order, gets it once. A page that asks getInitialURL
|
|
1386
|
+
// only later, after an await, should skip `initial` in onLink.
|
|
1387
|
+
//
|
|
1388
|
+
// The state lives on window, not in this function, because this
|
|
1389
|
+
// script can run twice in one document, and a second copy must
|
|
1390
|
+
// neither reset it nor buffer every link a second time.
|
|
1391
|
+
(function installDeepLinkReplay(craft) {
|
|
1392
|
+
var replay = window.__craftDeepLinkReplay;
|
|
1393
|
+
if (!replay) {
|
|
1394
|
+
replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
|
|
1395
|
+
window.addEventListener('craftDeepLink', function(e) {
|
|
1396
|
+
if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
function claimed(detail) {
|
|
1400
|
+
return replay.initialClaimed && detail && detail.initial;
|
|
1401
|
+
}
|
|
1402
|
+
craft._subscribeDeepLinks = function(callback) {
|
|
1403
|
+
var active = true;
|
|
1404
|
+
var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
|
|
1405
|
+
window.addEventListener('craftDeepLink', listener);
|
|
1406
|
+
if (!replay.subscribed) {
|
|
1407
|
+
replay.subscribed = true;
|
|
1408
|
+
setTimeout(function() {
|
|
1409
|
+
var pending = replay.undelivered;
|
|
1410
|
+
replay.undelivered = [];
|
|
1411
|
+
if (!active) return;
|
|
1412
|
+
pending.forEach(function(detail) { callback(detail); });
|
|
1413
|
+
}, 0);
|
|
1414
|
+
}
|
|
1415
|
+
return function() {
|
|
1416
|
+
active = false;
|
|
1417
|
+
window.removeEventListener('craftDeepLink', listener);
|
|
1418
|
+
};
|
|
1419
|
+
};
|
|
1420
|
+
craft._claimInitialDeepLink = function() {
|
|
1421
|
+
replay.initialClaimed = true;
|
|
1422
|
+
replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
|
|
1423
|
+
};
|
|
1424
|
+
})(window.craft);
|
|
1425
|
+
|
|
1349
1426
|
(function installCraftMobileContract(craft) {
|
|
1350
1427
|
var legacyShare = craft.share.bind(craft);
|
|
1351
1428
|
var legacyOpenCamera = craft.openCamera.bind(craft);
|
|
@@ -1477,6 +1554,11 @@ class CraftBridge(
|
|
|
1477
1554
|
val events = pendingEvents.toList()
|
|
1478
1555
|
pendingEvents.clear()
|
|
1479
1556
|
events.forEach { (name, data) -> sendEvent(name, data) }
|
|
1557
|
+
// After the script, so the page's replay listener exists and
|
|
1558
|
+
// the native deliverer is installed.
|
|
1559
|
+
val links = pendingDeepLinks.toList()
|
|
1560
|
+
pendingDeepLinks.clear()
|
|
1561
|
+
links.forEach { (url, initial) -> dispatchDeepLink(url, initial) }
|
|
1480
1562
|
}
|
|
1481
1563
|
}
|
|
1482
1564
|
}
|
|
@@ -1610,18 +1692,95 @@ class CraftBridge(
|
|
|
1610
1692
|
|
|
1611
1693
|
// ==================== Share ====================
|
|
1612
1694
|
|
|
1695
|
+
// Whether the person picked an app from the share menu that is open now.
|
|
1696
|
+
//
|
|
1697
|
+
// The chooser says so through the IntentSender below and says nothing at
|
|
1698
|
+
// all when it is dismissed, while its activity result comes back
|
|
1699
|
+
// RESULT_CANCELED either way. Neither signal answers on its own, so the
|
|
1700
|
+
// pick marks this and the result, which always arrives, settles the page's
|
|
1701
|
+
// promise with it. Everything that touches it runs on the main thread: the
|
|
1702
|
+
// launch is posted there, and receivers and results are delivered there.
|
|
1703
|
+
private var shareChosen = false
|
|
1704
|
+
private var shareReceiverRegistered = false
|
|
1705
|
+
private val shareChosenAction by lazy { "${activity.packageName}.CRAFT_SHARE_CHOSEN" }
|
|
1706
|
+
private val shareChosenReceiver = object : BroadcastReceiver() {
|
|
1707
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
1708
|
+
shareChosen = true
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1613
1712
|
@JavascriptInterface
|
|
1614
1713
|
fun share(text: String, title: String) {
|
|
1615
|
-
|
|
1714
|
+
// The same line iOS draws: an empty string is nothing to share, and a
|
|
1715
|
+
// chooser opened over nothing would offer apps a blank message.
|
|
1716
|
+
if (text.isEmpty()) {
|
|
1717
|
+
rejectShare("Nothing to share")
|
|
1718
|
+
return
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
activity.runOnUiThread {
|
|
1722
|
+
if (closed) return@runOnUiThread
|
|
1723
|
+
shareChosen = false
|
|
1724
|
+
|
|
1725
|
+
val chosen = try {
|
|
1726
|
+
shareChosenSender()
|
|
1727
|
+
} catch (error: Exception) {
|
|
1728
|
+
rejectShare(error.message ?: "Unable to open the share menu")
|
|
1729
|
+
return@runOnUiThread
|
|
1730
|
+
}
|
|
1616
1731
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1732
|
+
// Zig launches the same chooser for the same result; the answer
|
|
1733
|
+
// still comes back through onActivityResult below.
|
|
1734
|
+
if (CraftNative.share(activity, text, title, chosen, REQUEST_SHARE)) return@runOnUiThread
|
|
1735
|
+
|
|
1736
|
+
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
1737
|
+
type = "text/plain"
|
|
1738
|
+
putExtra(Intent.EXTRA_TEXT, text)
|
|
1739
|
+
if (title.isNotEmpty()) {
|
|
1740
|
+
putExtra(Intent.EXTRA_SUBJECT, title)
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
try {
|
|
1744
|
+
activity.startActivityForResult(Intent.createChooser(intent, "Share", chosen), REQUEST_SHARE)
|
|
1745
|
+
} catch (error: Exception) {
|
|
1746
|
+
rejectShare(error.message ?: "Unable to open the share menu")
|
|
1622
1747
|
}
|
|
1623
1748
|
}
|
|
1624
|
-
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// The IntentSender the chooser fires on a pick.
|
|
1752
|
+
//
|
|
1753
|
+
// Immutable: the chooser adds the chosen component to the intent it
|
|
1754
|
+
// fires, and this only needs to know that it fired. Explicit, by package,
|
|
1755
|
+
// so the broadcast reaches this app and nothing else, and so the
|
|
1756
|
+
// not-exported receiver accepts it.
|
|
1757
|
+
private fun shareChosenSender(): IntentSender {
|
|
1758
|
+
if (!shareReceiverRegistered) {
|
|
1759
|
+
ContextCompat.registerReceiver(
|
|
1760
|
+
activity,
|
|
1761
|
+
shareChosenReceiver,
|
|
1762
|
+
IntentFilter(shareChosenAction),
|
|
1763
|
+
ContextCompat.RECEIVER_NOT_EXPORTED
|
|
1764
|
+
)
|
|
1765
|
+
shareReceiverRegistered = true
|
|
1766
|
+
}
|
|
1767
|
+
val pick = Intent(shareChosenAction).setPackage(activity.packageName)
|
|
1768
|
+
return PendingIntent.getBroadcast(
|
|
1769
|
+
activity,
|
|
1770
|
+
REQUEST_SHARE,
|
|
1771
|
+
pick,
|
|
1772
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
1773
|
+
).intentSender
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private fun handleShareResult() {
|
|
1777
|
+
val chosen = shareChosen
|
|
1778
|
+
shareChosen = false
|
|
1779
|
+
evaluatePromiseJavascript("window._craftShareResolve && window._craftShareResolve($chosen)")
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
private fun rejectShare(message: String) {
|
|
1783
|
+
evaluatePromiseJavascript("window._craftShareReject && window._craftShareReject(${jsQuote(message)})")
|
|
1625
1784
|
}
|
|
1626
1785
|
|
|
1627
1786
|
// ==================== Camera & Gallery ====================
|
|
@@ -2366,10 +2525,49 @@ class CraftBridge(
|
|
|
2366
2525
|
activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode
|
|
2367
2526
|
}
|
|
2368
2527
|
} catch (e: Exception) { 0 })
|
|
2369
|
-
put("isEmulator",
|
|
2528
|
+
put("isEmulator", isEmulator())
|
|
2370
2529
|
}.toString()
|
|
2371
2530
|
}
|
|
2372
2531
|
|
|
2532
|
+
/**
|
|
2533
|
+
* Whether this is an emulator rather than a phone.
|
|
2534
|
+
*
|
|
2535
|
+
* This used to be `FINGERPRINT.contains("generic") ||
|
|
2536
|
+
* FINGERPRINT.contains("emulator")`, and it answered false on the AVDs
|
|
2537
|
+
* anyone actually runs. A current Google APIs image fingerprints as
|
|
2538
|
+
*
|
|
2539
|
+
* google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/...:user/release-keys
|
|
2540
|
+
*
|
|
2541
|
+
* which contains neither word. The mobile E2E suite caught it on an API 34
|
|
2542
|
+
* x86_64 emulator, reporting `isEmulator is false` from inside one.
|
|
2543
|
+
*
|
|
2544
|
+
* `HARDWARE` is the reliable signal and leads here: the AVD's virtual board
|
|
2545
|
+
* is `goldfish` on the old QEMU pipeline and `ranchu` on the current one,
|
|
2546
|
+
* and has been one of those two for over a decade. The rest are kept as
|
|
2547
|
+
* secondary evidence for images that report something else - Genymotion,
|
|
2548
|
+
* and the older `generic`/`sdk` products.
|
|
2549
|
+
*
|
|
2550
|
+
* Note this is a hint, not a security boundary. Anything that can rewrite
|
|
2551
|
+
* these properties can defeat it, and nothing here should be treated as
|
|
2552
|
+
* proof of where the code is running.
|
|
2553
|
+
*/
|
|
2554
|
+
private fun isEmulator(): Boolean {
|
|
2555
|
+
val hardware = Build.HARDWARE.lowercase()
|
|
2556
|
+
if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true
|
|
2557
|
+
|
|
2558
|
+
val fingerprint = Build.FINGERPRINT.lowercase()
|
|
2559
|
+
if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true
|
|
2560
|
+
if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true
|
|
2561
|
+
|
|
2562
|
+
val product = Build.PRODUCT.lowercase()
|
|
2563
|
+
if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true
|
|
2564
|
+
|
|
2565
|
+
val model = Build.MODEL.lowercase()
|
|
2566
|
+
if (model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for")) return true
|
|
2567
|
+
|
|
2568
|
+
return Build.MANUFACTURER.contains("Genymotion") || Build.BRAND.startsWith("generic")
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2373
2571
|
// ==================== Network Status ====================
|
|
2374
2572
|
|
|
2375
2573
|
@JavascriptInterface
|
|
@@ -3190,18 +3388,6 @@ class CraftBridge(
|
|
|
3190
3388
|
return true
|
|
3191
3389
|
}
|
|
3192
3390
|
|
|
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
3391
|
// ==================== QR/Barcode Scanner ====================
|
|
3206
3392
|
|
|
3207
3393
|
@JavascriptInterface
|
|
@@ -3659,7 +3845,7 @@ class CraftBridge(
|
|
|
3659
3845
|
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
|
3660
3846
|
if (closed) return requestCode == REQUEST_CAMERA || requestCode == REQUEST_GALLERY ||
|
|
3661
3847
|
requestCode == REQUEST_FILE_PICKER || requestCode == REQUEST_VIDEO ||
|
|
3662
|
-
requestCode == REQUEST_PICK_CONTACT
|
|
3848
|
+
requestCode == REQUEST_PICK_CONTACT || requestCode == REQUEST_SHARE
|
|
3663
3849
|
|
|
3664
3850
|
return when (requestCode) {
|
|
3665
3851
|
REQUEST_CAMERA, REQUEST_GALLERY -> {
|
|
@@ -3678,6 +3864,10 @@ class CraftBridge(
|
|
|
3678
3864
|
handleContactPickerResult(resultCode, data)
|
|
3679
3865
|
true
|
|
3680
3866
|
}
|
|
3867
|
+
REQUEST_SHARE -> {
|
|
3868
|
+
handleShareResult()
|
|
3869
|
+
true
|
|
3870
|
+
}
|
|
3681
3871
|
else -> healthConnect.onActivityResult(requestCode, resultCode, data)
|
|
3682
3872
|
}
|
|
3683
3873
|
}
|
|
@@ -3697,6 +3887,11 @@ class CraftBridge(
|
|
|
3697
3887
|
CraftNative.close(activity)
|
|
3698
3888
|
healthConnect.close()
|
|
3699
3889
|
|
|
3890
|
+
if (shareReceiverRegistered) {
|
|
3891
|
+
runCatching { activity.unregisterReceiver(shareChosenReceiver) }
|
|
3892
|
+
shareReceiverRegistered = false
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3700
3895
|
runCatching { speechRecognizer?.cancel() }
|
|
3701
3896
|
runCatching { speechRecognizer?.destroy() }
|
|
3702
3897
|
speechRecognizer = null
|
|
@@ -3756,6 +3951,7 @@ class CraftBridge(
|
|
|
3756
3951
|
isKeepingAwake = false
|
|
3757
3952
|
pendingPermissionRequests.clear()
|
|
3758
3953
|
pendingEvents.clear()
|
|
3954
|
+
pendingDeepLinks.clear()
|
|
3759
3955
|
}
|
|
3760
3956
|
|
|
3761
3957
|
// ==================== Screen Capture ====================
|
|
@@ -4529,13 +4725,44 @@ class CraftBridge(
|
|
|
4529
4725
|
}
|
|
4530
4726
|
}
|
|
4531
4727
|
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4728
|
+
/**
|
|
4729
|
+
* A link the activity received. The first one before any page was ready
|
|
4730
|
+
* launched the app: it becomes getInitialURL's answer and is dispatched
|
|
4731
|
+
* with `initial: true`. Every later link is `initial: false` and leaves
|
|
4732
|
+
* getInitialURL alone, as on iOS.
|
|
4733
|
+
*/
|
|
4734
|
+
fun receiveDeepLink(url: String) {
|
|
4735
|
+
val initial = !hasBeenReady && !initialAssigned
|
|
4736
|
+
if (initial) {
|
|
4737
|
+
initialAssigned = true
|
|
4738
|
+
launchURL = url
|
|
4739
|
+
setInitialURL(url)
|
|
4538
4740
|
}
|
|
4741
|
+
if (bridgeReady) dispatchDeepLink(url, false) else pendingDeepLinks.add(url to initial)
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
fun saveDeepLinks(outState: Bundle) {
|
|
4745
|
+
outState.putBoolean(STATE_LAUNCH_ASSIGNED, initialAssigned)
|
|
4746
|
+
outState.putBoolean(STATE_PAGE_WAS_READY, hasBeenReady)
|
|
4747
|
+
outState.putString(STATE_LAUNCH_URL, launchURL)
|
|
4748
|
+
}
|
|
4749
|
+
|
|
4750
|
+
/**
|
|
4751
|
+
* A recreated activity loads a new WebView and a fresh document, so the
|
|
4752
|
+
* launch link is queued again for that document's first subscriber, the
|
|
4753
|
+
* way it was for the first one; a page that called getInitialURL claims
|
|
4754
|
+
* it as before. Links that arrive now are never the launch link.
|
|
4755
|
+
*/
|
|
4756
|
+
fun restoreDeepLinks(savedState: Bundle) {
|
|
4757
|
+
initialAssigned = savedState.getBoolean(STATE_LAUNCH_ASSIGNED, true)
|
|
4758
|
+
hasBeenReady = savedState.getBoolean(STATE_PAGE_WAS_READY, true)
|
|
4759
|
+
launchURL = savedState.getString(STATE_LAUNCH_URL)
|
|
4760
|
+
setInitialURL(launchURL)
|
|
4761
|
+
launchURL?.let { pendingDeepLinks.add(it to true) }
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
private fun dispatchDeepLink(url: String, initial: Boolean) {
|
|
4765
|
+
if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return
|
|
4539
4766
|
|
|
4540
4767
|
val uri = Uri.parse(url)
|
|
4541
4768
|
val json = JSONObject().apply {
|
|
@@ -4550,6 +4777,7 @@ class CraftBridge(
|
|
|
4550
4777
|
queryParams.put(name, uri.getQueryParameter(name) ?: "")
|
|
4551
4778
|
}
|
|
4552
4779
|
put("queryParams", queryParams)
|
|
4780
|
+
put("initial", initial)
|
|
4553
4781
|
}
|
|
4554
4782
|
|
|
4555
4783
|
evaluateJavascriptUnlessClosed(
|
|
@@ -4620,6 +4848,9 @@ class CraftBridge(
|
|
|
4620
4848
|
}
|
|
4621
4849
|
|
|
4622
4850
|
companion object {
|
|
4851
|
+
private const val STATE_LAUNCH_ASSIGNED = "craft.deepLinks.launchAssigned"
|
|
4852
|
+
private const val STATE_PAGE_WAS_READY = "craft.deepLinks.pageWasReady"
|
|
4853
|
+
private const val STATE_LAUNCH_URL = "craft.deepLinks.launchURL"
|
|
4623
4854
|
private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$")
|
|
4624
4855
|
const val REQUEST_CAMERA = 1001
|
|
4625
4856
|
const val REQUEST_GALLERY = 1002
|
|
@@ -4631,6 +4862,7 @@ class CraftBridge(
|
|
|
4631
4862
|
const val REQUEST_VIDEO = 1008
|
|
4632
4863
|
const val REQUEST_BLUETOOTH = 1009
|
|
4633
4864
|
const val REQUEST_PICK_CONTACT = 1010
|
|
4865
|
+
const val REQUEST_SHARE = 1011
|
|
4634
4866
|
private const val PERMISSION_REQUEST_START = 2000
|
|
4635
4867
|
private const val PERMISSION_REQUEST_END = 2999
|
|
4636
4868
|
private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.craft.runtime
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.content.IntentSender
|
|
4
5
|
import com.google.android.gms.location.Priority
|
|
5
6
|
import com.google.android.gms.location.LocationServices
|
|
6
7
|
import com.google.android.gms.location.LocationResult
|
|
@@ -110,10 +111,24 @@ object CraftNative {
|
|
|
110
111
|
*/
|
|
111
112
|
val isAvailable: Boolean = try {
|
|
112
113
|
System.loadLibrary("craft")
|
|
114
|
+
// Said out loud, because the two outcomes below are otherwise
|
|
115
|
+
// indistinguishable from outside: a library that never shipped and a
|
|
116
|
+
// library that loaded and then failed to bind both end with the shim
|
|
117
|
+
// answering every call. Zig logs its own registration under the same
|
|
118
|
+
// tag, so the pair reads as a sequence.
|
|
119
|
+
android.util.Log.i("CraftNative", "craft: libcraft.so loaded")
|
|
113
120
|
true
|
|
114
121
|
} catch (e: UnsatisfiedLinkError) {
|
|
122
|
+
// The reason, not just the fact. This used to be a bare `false`, and
|
|
123
|
+
// the first time the runtime was genuinely shipped and genuinely
|
|
124
|
+
// failed to load, the only evidence anywhere was that nothing
|
|
125
|
+
// happened — no linker line, no exception, nothing in logcat at all.
|
|
126
|
+
// The message carries the dynamic linker's own complaint, which is the
|
|
127
|
+
// one thing that says *why*.
|
|
128
|
+
android.util.Log.w("CraftNative", "craft: libcraft.so did not load: ${e.message}")
|
|
115
129
|
false
|
|
116
130
|
} catch (e: SecurityException) {
|
|
131
|
+
android.util.Log.w("CraftNative", "craft: libcraft.so was refused: ${e.message}")
|
|
117
132
|
false
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -213,7 +228,7 @@ object CraftNative {
|
|
|
213
228
|
private external fun nativeClipboardRead(activity: Activity): String?
|
|
214
229
|
private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
|
|
215
230
|
private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
|
|
216
|
-
private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
|
|
231
|
+
private external fun nativeShare(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean
|
|
217
232
|
private external fun nativeOpenCamera(activity: Activity): Boolean
|
|
218
233
|
private external fun nativePickImage(activity: Activity): Boolean
|
|
219
234
|
private external fun nativePickFile(activity: Activity): Boolean
|
|
@@ -235,7 +250,7 @@ object CraftNative {
|
|
|
235
250
|
private external fun nativeResetDeepLinks(): Boolean
|
|
236
251
|
private external fun nativeSetInitialURL(url: String?): Boolean
|
|
237
252
|
private external fun nativeGetInitialURL(): Boolean
|
|
238
|
-
private external fun nativeDispatchDeepLink(url: String): Boolean
|
|
253
|
+
private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean
|
|
239
254
|
private external fun nativeScreenshotReady(bytes: ByteArray)
|
|
240
255
|
private external fun nativeScreenshotError(message: String)
|
|
241
256
|
private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean
|
|
@@ -411,11 +426,15 @@ object CraftNative {
|
|
|
411
426
|
}
|
|
412
427
|
}
|
|
413
428
|
|
|
414
|
-
/**
|
|
415
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Returns whether Zig launched the share menu for a result; false falls
|
|
431
|
+
* through. `chosen` fires on a pick and `requestCode` routes the result
|
|
432
|
+
* back to CraftBridge, which settles the page's promise from the two.
|
|
433
|
+
*/
|
|
434
|
+
fun share(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean {
|
|
416
435
|
if (!isAvailable) return false
|
|
417
436
|
return try {
|
|
418
|
-
nativeShare(activity, text, title)
|
|
437
|
+
nativeShare(activity, text, title, chosen, requestCode)
|
|
419
438
|
} catch (e: UnsatisfiedLinkError) {
|
|
420
439
|
false
|
|
421
440
|
}
|
|
@@ -697,9 +716,9 @@ object CraftNative {
|
|
|
697
716
|
return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false }
|
|
698
717
|
}
|
|
699
718
|
|
|
700
|
-
fun dispatchDeepLink(url: String): Boolean {
|
|
719
|
+
fun dispatchDeepLink(url: String, initial: Boolean): Boolean {
|
|
701
720
|
if (!isAvailable) return false
|
|
702
|
-
return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false }
|
|
721
|
+
return try { nativeDispatchDeepLink(url, initial) } catch (e: UnsatisfiedLinkError) { false }
|
|
703
722
|
}
|
|
704
723
|
|
|
705
724
|
@JvmStatic
|
|
@@ -44,7 +44,19 @@ class MainActivity : AppCompatActivity() {
|
|
|
44
44
|
|
|
45
45
|
webView = findViewById(R.id.webview)
|
|
46
46
|
craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
|
|
47
|
-
|
|
47
|
+
// The link that launched the app (#215). Queued until the page's bridge
|
|
48
|
+
// is injected, then dispatched once with `initial: true`, so a page that
|
|
49
|
+
// subscribes rather than calling getInitialURL still hears it. A
|
|
50
|
+
// relaunch from Recents carries the old intent, and was not opened by it.
|
|
51
|
+
// A recreated activity restores what the bridge saved: its intent may
|
|
52
|
+
// be a later warm link that onNewIntent set.
|
|
53
|
+
if (savedInstanceState == null) {
|
|
54
|
+
val launchLink = intent.data?.toString()
|
|
55
|
+
val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
|
56
|
+
if (launchLink != null && !launchedFromHistory) craftBridge.receiveDeepLink(launchLink)
|
|
57
|
+
} else {
|
|
58
|
+
craftBridge.restoreDeepLinks(savedInstanceState)
|
|
59
|
+
}
|
|
48
60
|
if (savedInstanceState == null) handleIncomingShortcut(intent)
|
|
49
61
|
|
|
50
62
|
setupWebView()
|
|
@@ -150,12 +162,6 @@ class MainActivity : AppCompatActivity() {
|
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
164
|
|
|
153
|
-
private fun handleIncomingDeepLink(source: Intent?, dispatch: Boolean) {
|
|
154
|
-
val url = source?.data?.toString() ?: return
|
|
155
|
-
craftBridge.setInitialURL(url)
|
|
156
|
-
if (dispatch) craftBridge.dispatchDeepLink(url)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
165
|
private fun handleIncomingShortcut(source: Intent?) {
|
|
160
166
|
source?.getStringExtra(SHORTCUT_TYPE_EXTRA)?.let(craftBridge::dispatchShortcut)
|
|
161
167
|
}
|
|
@@ -198,10 +204,15 @@ class MainActivity : AppCompatActivity() {
|
|
|
198
204
|
return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
override fun onSaveInstanceState(outState: Bundle) {
|
|
208
|
+
super.onSaveInstanceState(outState)
|
|
209
|
+
craftBridge.saveDeepLinks(outState)
|
|
210
|
+
}
|
|
211
|
+
|
|
201
212
|
override fun onNewIntent(intent: Intent) {
|
|
202
213
|
super.onNewIntent(intent)
|
|
203
214
|
setIntent(intent)
|
|
204
|
-
|
|
215
|
+
intent.data?.toString()?.let(craftBridge::receiveDeepLink)
|
|
205
216
|
handleIncomingShortcut(intent)
|
|
206
217
|
}
|
|
207
218
|
|