craft-native 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/dist/android/src/index.d.ts +36 -1
- package/dist/android/src/index.js +47 -0
- package/dist/android/templates/CraftBridge.kt.template +263 -31
- package/dist/android/templates/CraftNative.kt.template +26 -7
- package/dist/android/templates/MainActivity.kt.template +19 -8
- package/dist/api/mobile.d.ts +12 -2
- package/dist/cli.js +50 -3
- package/dist/index.cjs +12 -5
- package/dist/index.js +12 -5
- package/dist/ios/src/index.d.ts +1 -1
- package/dist/ios/src/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +233 -32
- package/dist/mobile.js +16 -8
- package/package.json +1 -1
|
@@ -37,6 +37,14 @@ export interface InitOptions {
|
|
|
37
37
|
packageName?: string;
|
|
38
38
|
output: string;
|
|
39
39
|
config?: Partial<CraftAndroidConfig>;
|
|
40
|
+
/**
|
|
41
|
+
* Where to find the Zig runtime's `<abi>/libcraft.so`.
|
|
42
|
+
*
|
|
43
|
+
* `null` means no runtime regardless of the environment; `undefined` falls
|
|
44
|
+
* through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's
|
|
45
|
+
* `runtimeDir`.
|
|
46
|
+
*/
|
|
47
|
+
runtimeDir?: string | null;
|
|
40
48
|
}
|
|
41
49
|
export interface BuildOptions {
|
|
42
50
|
htmlPath?: string;
|
|
@@ -44,6 +52,8 @@ export interface BuildOptions {
|
|
|
44
52
|
output: string;
|
|
45
53
|
release?: boolean;
|
|
46
54
|
compile?: boolean;
|
|
55
|
+
/** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
|
|
56
|
+
runtimeDir?: string | null;
|
|
47
57
|
}
|
|
48
58
|
export interface OpenOptions {
|
|
49
59
|
output: string;
|
|
@@ -56,8 +66,33 @@ export declare function syncAndroidWebAssets(source: string, output: string): vo
|
|
|
56
66
|
export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
|
|
57
67
|
export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
|
|
58
68
|
/**
|
|
59
|
-
*
|
|
69
|
+
* Where the runtime directory comes from when the caller does not say.
|
|
70
|
+
*
|
|
71
|
+
* The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an
|
|
72
|
+
* explicit override for the monorepo dev loop, not a lookup path. Shipping the
|
|
73
|
+
* runtime to real apps means putting these libraries in the pantry package
|
|
74
|
+
* beside the `craft` binary, which is a distribution decision this function
|
|
75
|
+
* does not make.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveRuntimeDir(override?: string | null): string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Copy the Zig runtime into the generated project as
|
|
80
|
+
* `app/src/main/jniLibs/<abi>/libcraft.so`.
|
|
81
|
+
*
|
|
82
|
+
* That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle
|
|
83
|
+
* templates has to know about it — the library is packaged into the APK and
|
|
84
|
+
* `System.loadLibrary("craft")` finds it because the file is named for the
|
|
85
|
+
* `craft` it asks for.
|
|
86
|
+
*
|
|
87
|
+
* Until this existed, nothing put the library anywhere. `CraftNative` caught
|
|
88
|
+
* the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell
|
|
89
|
+
* through to the Kotlin shim — by design, so an app with no runtime still
|
|
90
|
+
* works, which is also why nobody noticed that *every* generated app was in
|
|
91
|
+
* that state and the whole Android half of the Zig bridge had never run.
|
|
92
|
+
*
|
|
93
|
+
* Returns true when a runtime was installed.
|
|
60
94
|
*/
|
|
95
|
+
export declare function installRuntime(output: string, runtimeDir: string): boolean;
|
|
61
96
|
export declare function init(options: InitOptions): Promise<void>;
|
|
62
97
|
/**
|
|
63
98
|
* Build Android project
|
|
@@ -318,6 +318,37 @@ function renderAndroidDeepLinks(config) {
|
|
|
318
318
|
</intent-filter>`).join(`
|
|
319
319
|
`);
|
|
320
320
|
}
|
|
321
|
+
var RUNTIME_ABIS = ["arm64-v8a", "x86_64"];
|
|
322
|
+
function resolveRuntimeDir(override) {
|
|
323
|
+
if (override === null)
|
|
324
|
+
return null;
|
|
325
|
+
const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME;
|
|
326
|
+
if (!dir)
|
|
327
|
+
return null;
|
|
328
|
+
if (!existsSync(dir)) {
|
|
329
|
+
const source = override === undefined ? "CRAFT_ANDROID_RUNTIME points at" : "runtimeDir is";
|
|
330
|
+
throw new Error(`${source} ${dir}, which does not exist.`);
|
|
331
|
+
}
|
|
332
|
+
return dir;
|
|
333
|
+
}
|
|
334
|
+
function installRuntime(output, runtimeDir) {
|
|
335
|
+
const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync(entry.source));
|
|
336
|
+
if (found.length === 0) {
|
|
337
|
+
throw new Error(`${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(", ")}. ` + "Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.");
|
|
338
|
+
}
|
|
339
|
+
if (found.length < RUNTIME_ABIS.length) {
|
|
340
|
+
const missing = RUNTIME_ABIS.filter((abi) => !found.some((entry) => entry.abi === abi));
|
|
341
|
+
console.warn(` \u26A0 only ${found.map((entry) => entry.abi).join(", ")} was found; ${missing.join(", ")} is missing. ` + "The app will fall back to the Kotlin shim on those devices.");
|
|
342
|
+
}
|
|
343
|
+
const dest = join(output, "app/src/main/jniLibs");
|
|
344
|
+
rmSync(dest, { force: true, recursive: true });
|
|
345
|
+
for (const { abi, source } of found) {
|
|
346
|
+
const abiDir = join(dest, abi);
|
|
347
|
+
mkdirSync(abiDir, { recursive: true });
|
|
348
|
+
cpSync(source, join(abiDir, "libcraft.so"));
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
321
352
|
async function init(options) {
|
|
322
353
|
const { name, packageName, output } = options;
|
|
323
354
|
console.log(`
|
|
@@ -537,6 +568,11 @@ zipStorePath=wrapper/dists
|
|
|
537
568
|
</body>
|
|
538
569
|
</html>`;
|
|
539
570
|
writeFileSync(join(output, "app/src/main/assets/index.html"), placeholderHtml);
|
|
571
|
+
const runtimeDir = resolveRuntimeDir(options.runtimeDir);
|
|
572
|
+
if (runtimeDir) {
|
|
573
|
+
installRuntime(output, runtimeDir);
|
|
574
|
+
console.log(" Installed the Zig runtime from", runtimeDir);
|
|
575
|
+
}
|
|
540
576
|
console.log("\u2705 Project initialized");
|
|
541
577
|
console.log("");
|
|
542
578
|
console.log("Next steps:");
|
|
@@ -555,6 +591,15 @@ async function build(options) {
|
|
|
555
591
|
throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
|
|
556
592
|
}
|
|
557
593
|
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
594
|
+
if (existsSync(join(output, "app/src/main/jniLibs"))) {
|
|
595
|
+
const runtimeDir = resolveRuntimeDir(options.runtimeDir);
|
|
596
|
+
if (runtimeDir) {
|
|
597
|
+
installRuntime(output, runtimeDir);
|
|
598
|
+
console.log(" Refreshed the Zig runtime from", runtimeDir);
|
|
599
|
+
} else {
|
|
600
|
+
console.log(" Keeping the Zig runtime installed at init (no runtime directory configured)");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
558
603
|
if (devServer) {
|
|
559
604
|
const url = androidWebUrl(devServer, "Android dev server URL");
|
|
560
605
|
config.devServerURL = url.toString();
|
|
@@ -641,9 +686,11 @@ async function run(options) {
|
|
|
641
686
|
export {
|
|
642
687
|
syncAndroidWebAssets,
|
|
643
688
|
run,
|
|
689
|
+
resolveRuntimeDir,
|
|
644
690
|
renderAndroidPermissions,
|
|
645
691
|
renderAndroidDeepLinks,
|
|
646
692
|
open,
|
|
693
|
+
installRuntime,
|
|
647
694
|
init,
|
|
648
695
|
build
|
|
649
696
|
};
|
|
@@ -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
|