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.
@@ -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
- * Initialize a new Android project
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
- CraftAndroid.share(text, title || '');
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.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
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.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
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
- if (CraftNative.share(activity, text, title)) return
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
- val intent = Intent(Intent.ACTION_SEND).apply {
1618
- type = "text/plain"
1619
- putExtra(Intent.EXTRA_TEXT, text)
1620
- if (title.isNotEmpty()) {
1621
- putExtra(Intent.EXTRA_SUBJECT, title)
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
- activity.startActivity(Intent.createChooser(intent, "Share"))
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", Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("emulator"))
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
- fun dispatchDeepLink(url: String) {
4533
- if (nativeDeepLinks && CraftNative.dispatchDeepLink(url)) return
4534
-
4535
- // Store as initial URL if this is the first one
4536
- if (initialURL == null) {
4537
- initialURL = url
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
- /** Returns whether Zig launched the share sheet; false falls through. */
415
- fun share(activity: Activity, text: String, title: String): Boolean {
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