Closes #215.
What was wrong
On Android, the link that launched the app went only to getInitialURL(). A page that subscribed with onLink never learned how it was opened, which is #198's failure on iOS. And every warm link replaced getInitialURL's answer: in MainActivity through setInitialURL, and on the Zig path through rememberFirst.
What it does now
The Android side now meets the contract iOS has had since #216.
Native
- The launch link is queued until the page's bridge has been injected, then dispatched once with
initial: true. The flush runs in the injection's completion callback, after the page's replay listener and the native deliverer exist. - Later links are
initial: falseand leavegetInitialURLalone. - Zig takes the flag over JNI:
nativeDispatchDeepLinkis now(Ljava/lang/String;Z)Z, and the descriptor is pinned in a Zig test. - A relaunch from Recents (
FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) is not treated as opened by its old link. - A recreated activity restores the launch link from saved instance state, not from
getIntent(), whichonNewIntentmay have replaced with a warm link. The link is queued again for the new WebView's fresh document.
Page
- The replay block from iOS: links wait for the first subscriber, and
onLink/onDeepLinkreturn an unsubscribe. - Its state lives on
window, because Android can run the script twice in one document.
A fix for both platforms
A craftReady handler runs before native has dispatched anything. So when it called getInitialURL() and then onLink(), the claim found nothing to remove, and the launch link was delivered a second time. That was already true on iOS, and porting the block unchanged would have made it true on Android, where that pattern currently gets the link once.
getInitialURL now sets a sticky claim that also stops the launch link reaching live subscribers. The block is identical in CraftApp.swift.
A page that calls getInitialURL() only after an await still gets the launch link from both, as on iOS since #216. Such a page can now tell the two apart:
craft.d.tssays so;- the SDK's
deepLinks.onLinkpasses{ initial }as a second argument, where it used to drop it; examples/basic-app.tsskipsinitialin its handler.
Tests that fail without the fix
packages/android/src/page-script.test.ts(new) runs the real injected script under Bun.- Against
main's template, 5 of 9 fail: subscribe-late, the unsubscribe return, thecraftReadycase, the shared replay and unsubscribe-in-the-same-tick. - Removing the claim flag fails the
craftReadycase. - Removing the
windowstate fails the reinjection case.
- Against
packages/ios/src/page-script.test.tsgains deep-link cases. ThecraftReadyone fails againstmain.packages/android/src/index.test.tspins the generated Kotlin: the launch link queued rather than dropped, flushed afterbridgeReady,initialin the detail, no first-link overwrite, save and restore on recreation, and the JNI signature.- E2E: each Android leg now cold-starts the app twice through
crafte2eprobe://…withadb shell am start -W -a VIEW -d '<link>' <package>. That resolves through the generated intent filter, and-Wmust reportLaunchState: COLD. The two modes and their judgement are iOS's own: subscribe-only must get the link once, marked initial;getInitialURLplus subscribe must get it once, fromgetInitialURL. The report is read from logcat. The runtime leg also requires Zig to register, and not decline, on each cold start.
Not verified locally
This machine has no Android toolchain. The Kotlin compile (android-builder), the Zig JNI path and the emulator runs are only checked by this PR's CI.
Locally: bun run test, bun run typecheck, bun run verify:packages, Pickier, the android/ios/typescript package tests, 55 protocol tests, and the Swift template typecheck.
Filed along the way
- #228: bridge injection is not idempotent, and a stale injection callback can mark the wrong page ready.
- #229: the Recents relaunch re-fires the launch shortcut. Its deep-link half is fixed here.
- #230: compare JNI descriptors with the Kotlin
external funtypes, not just names. - #231:
CraftNative.deliverdrops scripts silently with no deliverer. - A comment on #209:
enableDeepLinksis not enforced on Android.