ReviewOS

also looking at this

craft-native/craft

fix(android): hand the launch link to the page that subscribes for it

#232
Merged glennmichael123 wants to merge fix/android-deep-link-replay into main
17 files +776 -136
.github/workflows/README.mdmodified+8-0
Changes to .github/workflows/README.md
@@ -76,6 +76,14 @@ that part. It waits until `dumpsys window` shows the chooser holding input
7676focus, saves `share-menu.png`, presses Back, and fails the run if the page asked
7777for a dismissal and no menu ever appeared.
7878
79After the suite, each leg also cold-starts the app through a link twice, the
80same two ways as iOS, with `adb shell am start -W -a android.intent.action.VIEW
81-d '<link>' <package>`. Naming the package rather than the activity means the
82link is resolved through the generated manifest's intent filter, and `-W` must
83report `LaunchState: COLD`, so a link delivered to an app that was still
84running cannot pass for one that launched it. The page writes its report to
85logcat, and each launch keeps its own `deeplink-<mode>-logcat.txt`.
86
7987The runtime leg fails on any line where a Zig native gave up — "fell through
8088to the shim", "failed with no fallback" or "could not reach the page" — because
8189Kotlin answering in its place is invisible to the page, and a runtime that has
packages/android/src/index.test.tsmodified+32-0
Changes to packages/android/src/index.test.ts
@@ -327,6 +327,38 @@ describe('Craft Android builder', () => {
327327 expect(bridge).toContain('craft.ar.onPlaneDetected is unavailable because ARCore requires native Activity integration')
328328 })
329329
330 it('queues deep links until the bridge is injected, and marks only the launch link initial', async () => {
331 // #215: the launch link used to go only to getInitialURL, and a warm link
332 // replaced the launch link as getInitialURL's answer.
333 const output = mkdtempSync(join(tmpdir(), 'craft-android-deep-links-'))
334 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output })
335
336 const sourceRoot = join(output, 'app/src/main/java/org/wildloop/app')
337 const bridge = readFileSync(join(sourceRoot, 'CraftBridge.kt'), 'utf8')
338 const activity = readFileSync(join(sourceRoot, 'MainActivity.kt'), 'utf8')
339 const holder = readFileSync(join(output, 'app/src/main/java/com/craft/runtime/CraftNative.kt'), 'utf8')
340
341 expect(activity).not.toContain('dispatch = false')
342 expect(activity).toContain('craftBridge.receiveDeepLink(launchLink)')
343 expect(activity).toContain('Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY')
344 expect(activity).toContain('intent.data?.toString()?.let(craftBridge::receiveDeepLink)')
345 // A recreated activity restores what the bridge saved, not getIntent(),
346 // which onNewIntent may have replaced with a warm link.
347 expect(activity).toContain('craftBridge.saveDeepLinks(outState)')
348 expect(activity).toContain('craftBridge.restoreDeepLinks(savedInstanceState)')
349
350 // Flushed in the injection's completion, after the page's replay exists.
351 const injected = bridge.slice(bridge.indexOf('webView.evaluateJavascript(script) {'))
352 const flush = injected.slice(0, injected.indexOf('fun markBridgeLoading()'))
353 expect(flush.indexOf('bridgeReady = true')).toBeLessThan(flush.indexOf('links.forEach { (url, initial) -> dispatchDeepLink(url, initial) }'))
354
355 expect(bridge).toContain('val initial = !hasBeenReady && !initialAssigned')
356 expect(bridge).toContain('put("initial", initial)')
357 expect(bridge).not.toContain('if (initialURL == null)')
358 expect(bridge).toContain('pendingDeepLinks.clear()\n }')
359 expect(holder).toContain('private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean')
360 })
361
330362 it('routes external Activity results back to every pending media promise', async () => {
331363 const output = mkdtempSync(join(tmpdir(), 'craft-android-activity-results-'))
332364 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output })
packages/android/src/page-script.test.tsadded+217-0
Changes to packages/android/src/page-script.test.ts
@@ -0,0 +1,217 @@
1import { readFileSync } from 'node:fs'
2import { join } from 'node:path'
3import { describe, expect, it } from 'bun:test'
4import { renderAndroidPromiseRuntime } from './promise-runtime'
5
6// The script CraftBridge injects into every page, run here against a fake
7// CraftAndroid. Nothing else runs it off a device, and a syntax error in it
8// compiles fine in Kotlin and leaves window.craft undefined on the emulator.
9
10const template = readFileSync(join(import.meta.dir, '../templates/CraftBridge.kt.template'), 'utf8')
11
12/** The page script, as Kotlin evaluates it, with every flag off. */
13function pageScript(): string {
14 const opening = ' val script = """\n'
15 const start = template.indexOf(opening)
16 if (start === -1) throw new Error('CraftBridge.kt.template no longer builds `val script = """`')
17 const end = template.indexOf('\n """.trimIndent()', start)
18 const script = template
19 .slice(start + opening.length, end)
20 .replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(' '))
21 .replace(/\{\{ENABLE_[A-Z_]+\}\}/g, 'false')
22 // Kotlin string templates, `${isBiometricAvailable()}` and the like. Each
23 // is a Boolean in the capabilities object.
24 .replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, 'false')
25 if (/\{\{|\$/.test(script)) throw new Error('the page script still holds a template placeholder')
26 return script
27}
28
29type Listener = (event: { type: string, detail: unknown }) => void
30
31/** `beforeInject` runs first, the way a page's own script runs before Android injects the bridge. */
32function loadPage(beforeInject?: (page: Record<string, any>) => void) {
33 const calls: string[] = []
34 const listeners: Record<string, Listener[]> = {}
35 const page: Record<string, any> = {
36 addEventListener: (type: string, listener: Listener) => {
37 (listeners[type] ||= []).push(listener)
38 },
39 removeEventListener: (type: string, listener: Listener) => {
40 listeners[type] = (listeners[type] || []).filter(existing => existing !== listener)
41 },
42 dispatchEvent: (event: { type: string, detail: unknown }) => {
43 for (const listener of [...(listeners[event.type] || [])]) listener(event)
44 return true
45 },
46 location: { href: 'https://appassets.androidplatform.net/index.html' },
47 }
48 page.window = page
49 class CustomEvent {
50 type: string
51 detail: unknown
52 constructor(type: string, options?: { detail?: unknown }) {
53 this.type = type
54 this.detail = options?.detail
55 }
56 }
57 // Every method the page calls answers undefined and is recorded by name.
58 const CraftAndroid = new Proxy({}, {
59 get: (_, name: string) => (..._args: unknown[]) => {
60 calls.push(name)
61 return undefined
62 },
63 })
64 const quiet = { log() {}, warn() {}, error() {}, info() {}, debug() {} }
65 const document = { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', readyState: 'complete' }
66
67 const inject = () => {
68 // eslint-disable-next-line no-new-func
69 new Function('window', 'document', 'CraftAndroid', 'CustomEvent', 'console', pageScript())(
70 page,
71 document,
72 CraftAndroid,
73 CustomEvent,
74 quiet,
75 )
76 }
77 beforeInject?.(page)
78 inject()
79
80 return {
81 page,
82 calls,
83 inject,
84 get craft() { return page.craft },
85 // What CraftBridge.dispatchDeepLink evaluates, once the script has run.
86 link: (url: string, initial: boolean) =>
87 page.dispatchEvent(new CustomEvent('craftDeepLink', { detail: { url, initial } })),
88 }
89}
90
91const tick = () => new Promise(resolve => setTimeout(resolve, 5))
92
93describe('the injected Android page script', () => {
94 // #215: every one of these received nothing before, because a link was
95 // dispatched once, to whoever had subscribed by then, which was nobody.
96 it('hands the launch link to a page that subscribes after it arrived', async () => {
97 const page = loadPage()
98 page.link('app://launch', true)
99
100 const seen: unknown[] = []
101 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
102 await tick()
103
104 expect(seen).toEqual([{ url: 'app://launch', initial: true }])
105 })
106
107 it('returns an unsubscribe from onLink and onDeepLink', () => {
108 const page = loadPage()
109 expect(typeof page.craft.deepLinks.onLink(() => {})).toBe('function')
110 expect(typeof page.craft.onDeepLink(() => {})).toBe('function')
111 })
112
113 it('leaves the launch link to getInitialURL when the page asks for it first', async () => {
114 const page = loadPage()
115 page.link('app://launch', true)
116
117 const initial = page.craft.deepLinks.getInitialURL()
118 const seen: unknown[] = []
119 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
120 await tick()
121
122 expect(seen).toEqual([])
123 expect(page.calls).toContain('getInitialURL')
124 page.page._craftDeepLinkResolve({ url: 'app://launch' })
125 expect(await initial).toEqual({ url: 'app://launch' })
126 })
127
128 it('leaves the launch link to getInitialURL when the page asks for it second', async () => {
129 const page = loadPage()
130 page.link('app://launch', true)
131
132 const seen: unknown[] = []
133 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
134 void page.craft.deepLinks.getInitialURL()
135 await tick()
136
137 expect(seen).toEqual([])
138 })
139
140 it('delivers the launch link once to a craftReady handler that asks for it both ways', async () => {
141 // The handler runs inside the script, before native has dispatched the
142 // link at all, so there is nothing held yet for getInitialURL to claim.
143 const seen: unknown[] = []
144 let subscribed = false
145 const page = loadPage((window) => {
146 window.addEventListener('craftReady', () => {
147 void window.craft.deepLinks.getInitialURL()
148 subscribed = typeof window.craft.deepLinks.onLink((detail: unknown) => seen.push(detail)) === 'function'
149 })
150 })
151 page.link('app://launch', true)
152 await tick()
153
154 expect(subscribed).toBe(true)
155 expect(page.calls).toContain('getInitialURL')
156 expect(seen).toEqual([])
157
158 // And the subscription is live: only the launch link was held back.
159 page.link('app://warm', false)
160 expect(seen).toEqual([{ url: 'app://warm', initial: false }])
161 })
162
163 it('shares one replay between onDeepLink and deepLinks.onLink', async () => {
164 const page = loadPage()
165 page.link('app://launch', true)
166
167 const legacy: unknown[] = []
168 const current: unknown[] = []
169 page.craft.onDeepLink((detail: unknown) => legacy.push(detail))
170 page.craft.deepLinks.onLink((detail: unknown) => current.push(detail))
171 await tick()
172
173 expect(legacy).toEqual([{ url: 'app://launch', initial: true }])
174 expect(current).toEqual([])
175 })
176
177 it('drops held links for a subscriber that unsubscribed in the same tick', async () => {
178 const page = loadPage()
179 page.link('app://launch', true)
180
181 const seen: unknown[] = []
182 const unsubscribe = page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
183 unsubscribe()
184 await tick()
185 page.link('app://warm', false)
186
187 expect(seen).toEqual([])
188 })
189
190 it('delivers a link that arrives after subscribing once, as it arrives', async () => {
191 const page = loadPage()
192 const seen: unknown[] = []
193 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
194 await tick()
195 page.link('app://warm', false)
196
197 expect(seen).toEqual([{ url: 'app://warm', initial: false }])
198 })
199
200 it('does not hand a link to a second subscriber after the script runs again in the same page', async () => {
201 const page = loadPage()
202 const first: unknown[] = []
203 page.craft.deepLinks.onLink((detail: unknown) => first.push(detail))
204 await tick()
205
206 page.inject()
207 page.link('app://warm', false)
208 const second: unknown[] = []
209 page.craft.deepLinks.onLink((detail: unknown) => second.push(detail))
210 await tick()
211
212 // The first subscriber heard it live. A second copy of the script that
213 // buffered it again would replay it to the next subscriber as well.
214 expect(first).toEqual([{ url: 'app://warm', initial: false }])
215 expect(second).toEqual([])
216 })
217})
packages/android/templates/CraftBridge.kt.templatemodified+118-20
Changes to packages/android/templates/CraftBridge.kt.template
@@ -221,6 +221,14 @@ class CraftBridge(
221221 private var bridgeReady = false
222222 private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
223223
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
224232 // Location tracking
225233 private var fusedLocationClient: FusedLocationProviderClient? = null
226234 private val locationCallbacks = mutableMapOf<Int, LocationCallback>()
@@ -310,6 +318,10 @@ class CraftBridge(
310318 // still loading has somewhere to go rather than being counted as
311319 // dropped.
312320 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
313325
314326 val script = """
315327
@@ -598,7 +610,7 @@ class CraftBridge(
598610
599611 // Deep Links
600612 onDeepLink: function(callback) {
601 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
613 return window.craft._subscribeDeepLinks(callback);
602614 },
603615
604616 // QR/Barcode Scanner
@@ -999,12 +1011,13 @@ class CraftBridge(
9991011 // Deep Links
10001012 deepLinks: {
10011013 getInitialURL: function() {
1014 window.craft._claimInitialDeepLink();
10021015 return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() {
10031016 CraftAndroid.getInitialURL();
10041017 });
10051018 },
10061019 onLink: function(callback) {
1007 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
1020 return window.craft._subscribeDeepLinks(callback);
10081021 }
10091022 },
10101023
@@ -1354,6 +1367,62 @@ class CraftBridge(
13541367 }
13551368 };
13561369
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
13571426 (function installCraftMobileContract(craft) {
13581427 var legacyShare = craft.share.bind(craft);
13591428 var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -1485,6 +1554,11 @@ class CraftBridge(
14851554 val events = pendingEvents.toList()
14861555 pendingEvents.clear()
14871556 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) }
14881562 }
14891563 }
14901564 }
@@ -3314,18 +3388,6 @@ class CraftBridge(
33143388 return true
33153389 }
33163390
3317 // ==================== Deep Links ====================
3318
3319 fun handleDeepLink(uri: android.net.Uri) {
3320 sendEvent("craftDeepLink", mapOf(
3321 "url" to uri.toString(),
3322 "scheme" to (uri.scheme ?: ""),
3323 "host" to (uri.host ?: ""),
3324 "path" to (uri.path ?: ""),
3325 "query" to (uri.query ?: "")
3326 ))
3327 }
3328
33293391 // ==================== QR/Barcode Scanner ====================
33303392
33313393 @JavascriptInterface
@@ -3889,6 +3951,7 @@ class CraftBridge(
38893951 isKeepingAwake = false
38903952 pendingPermissionRequests.clear()
38913953 pendingEvents.clear()
3954 pendingDeepLinks.clear()
38923955 }
38933956
38943957 // ==================== Screen Capture ====================
@@ -4662,13 +4725,44 @@ class CraftBridge(
46624725 }
46634726 }
46644727
4665 fun dispatchDeepLink(url: String) {
4666 if (nativeDeepLinks && CraftNative.dispatchDeepLink(url)) return
4667
4668 // Store as initial URL if this is the first one
4669 if (initialURL == null) {
4670 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)
46714740 }
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
46724766
46734767 val uri = Uri.parse(url)
46744768 val json = JSONObject().apply {
@@ -4683,6 +4777,7 @@ class CraftBridge(
46834777 queryParams.put(name, uri.getQueryParameter(name) ?: "")
46844778 }
46854779 put("queryParams", queryParams)
4780 put("initial", initial)
46864781 }
46874782
46884783 evaluateJavascriptUnlessClosed(
@@ -4753,6 +4848,9 @@ class CraftBridge(
47534848 }
47544849
47554850 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"
47564854 private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$")
47574855 const val REQUEST_CAMERA = 1001
47584856 const val REQUEST_GALLERY = 1002
packages/android/templates/CraftNative.kt.templatemodified+3-3
Changes to packages/android/templates/CraftNative.kt.template
@@ -250,7 +250,7 @@ object CraftNative {
250250 private external fun nativeResetDeepLinks(): Boolean
251251 private external fun nativeSetInitialURL(url: String?): Boolean
252252 private external fun nativeGetInitialURL(): Boolean
253 private external fun nativeDispatchDeepLink(url: String): Boolean
253 private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean
254254 private external fun nativeScreenshotReady(bytes: ByteArray)
255255 private external fun nativeScreenshotError(message: String)
256256 private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean
@@ -716,9 +716,9 @@ object CraftNative {
716716 return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false }
717717 }
718718
719 fun dispatchDeepLink(url: String): Boolean {
719 fun dispatchDeepLink(url: String, initial: Boolean): Boolean {
720720 if (!isAvailable) return false
721 return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false }
721 return try { nativeDispatchDeepLink(url, initial) } catch (e: UnsatisfiedLinkError) { false }
722722 }
723723
724724 @JvmStatic
packages/android/templates/MainActivity.kt.templatemodified+19-8
Changes to packages/android/templates/MainActivity.kt.template
@@ -44,7 +44,19 @@ class MainActivity : AppCompatActivity() {
4444
4545 webView = findViewById(R.id.webview)
4646 craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
47 handleIncomingDeepLink(intent, dispatch = false)
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 }
4860 if (savedInstanceState == null) handleIncomingShortcut(intent)
4961
5062 setupWebView()
@@ -150,12 +162,6 @@ class MainActivity : AppCompatActivity() {
150162 }
151163 }
152164
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
159165 private fun handleIncomingShortcut(source: Intent?) {
160166 source?.getStringExtra(SHORTCUT_TYPE_EXTRA)?.let(craftBridge::dispatchShortcut)
161167 }
@@ -198,10 +204,15 @@ class MainActivity : AppCompatActivity() {
198204 return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins
199205 }
200206
207 override fun onSaveInstanceState(outState: Bundle) {
208 super.onSaveInstanceState(outState)
209 craftBridge.saveDeepLinks(outState)
210 }
211
201212 override fun onNewIntent(intent: Intent) {
202213 super.onNewIntent(intent)
203214 setIntent(intent)
204 handleIncomingDeepLink(intent, dispatch = true)
215 intent.data?.toString()?.let(craftBridge::receiveDeepLink)
205216 handleIncomingShortcut(intent)
206217 }
207218
packages/ios/src/page-script.test.tsmodified+46-5
Changes to packages/ios/src/page-script.test.ts
@@ -23,17 +23,22 @@ function pageScript(): string {
2323
2424interface Post { action: string, callbackId?: string, [key: string]: unknown }
2525
26function loadPage() {
26type Listener = (event: { type: string, detail: unknown }) => void
27
28/** `beforeInject` runs first, the way a page's own script runs before WebKit's didFinish injects the bridge. */
29function loadPage(beforeInject?: (page: Record<string, any>) => void) {
2730 const posts: Post[] = []
28 const listeners: Record<string, ((event: { type: string, detail: unknown }) => void)[]> = {}
31 const listeners: Record<string, Listener[]> = {}
2932 const page: Record<string, any> = {
3033 webkit: { messageHandlers: { craft: { postMessage: (message: Post) => posts.push(message) } } },
31 addEventListener: (type: string, listener: (event: { type: string, detail: unknown }) => void) => {
34 addEventListener: (type: string, listener: Listener) => {
3235 (listeners[type] ||= []).push(listener)
3336 },
34 removeEventListener: () => {},
37 removeEventListener: (type: string, listener: Listener) => {
38 listeners[type] = (listeners[type] || []).filter(existing => existing !== listener)
39 },
3540 dispatchEvent: (event: { type: string, detail: unknown }) => {
36 for (const listener of listeners[event.type] || []) listener(event)
41 for (const listener of [...(listeners[event.type] || [])]) listener(event)
3742 return true
3843 },
3944 location: { href: 'craft://app/' },
@@ -47,6 +52,7 @@ function loadPage() {
4752 }
4853 }
4954 const quiet = { log() {}, warn() {}, error() {} }
55 beforeInject?.(page)
5056 // eslint-disable-next-line no-new-func
5157 new Function('window', 'document', 'navigator', 'CustomEvent', 'console', pageScript())(
5258 page,
@@ -69,6 +75,9 @@ function loadPage() {
6975 // What Swift's resolveCallback and rejectCallback evaluate.
7076 answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value),
7177 refuse: (action: string, message: string, code: string) => craft._rejectCallback(last(action).callbackId, message, code),
78 // What DeepLinkManager.dispatchDeepLink evaluates, once the script has run.
79 link: (url: string, initial: boolean) =>
80 page.dispatchEvent(new CustomEvent('craftDeepLink', { detail: { url, initial } })),
7281 }
7382}
7483
@@ -144,4 +153,36 @@ describe('the injected iOS page script', () => {
144153 await expect(settled).rejects.toMatchObject({ code: 'NATIVE_CALL_FAILED' })
145154 })
146155 }
156
157 // #198 and #215. The Android page script runs the same block; its own test
158 // covers the rest of the contract.
159 const tick = () => new Promise(resolve => setTimeout(resolve, 5))
160
161 it('hands the launch link to a page that subscribes after it arrived', async () => {
162 const page = loadPage()
163 page.link('app://launch', true)
164
165 const seen: unknown[] = []
166 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
167 await tick()
168
169 expect(seen).toEqual([{ url: 'app://launch', initial: true }])
170 })
171
172 it('delivers the launch link once to a craftReady handler that asks for it both ways', async () => {
173 // The handler runs inside the script, before native has dispatched the
174 // link at all, so there is nothing held yet for getInitialURL to claim.
175 const seen: unknown[] = []
176 const page = loadPage((window) => {
177 window.addEventListener('craftReady', () => {
178 void window.craft.deepLinks.getInitialURL()
179 window.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
180 })
181 })
182 page.link('app://launch', true)
183 await tick()
184
185 expect(seen).toEqual([])
186 expect(page.last('getInitialURL').callbackId).toMatch(/^cb_\d+$/)
187 })
147188})
packages/ios/templates/CraftApp.swiftmodified+26-14
Changes to packages/ios/templates/CraftApp.swift
@@ -2907,24 +2907,35 @@ struct CraftWebView: UIViewRepresentable {
29072907 //
29082908 // Held here instead, and handed to the first subscriber on the
29092909 // next turn, so an unsubscribe returned in the same tick still
2910 // applies. The launch link is withdrawn when the page calls
2911 // getInitialURL, its other way of receiving it, so a page that
2912 // does both in the same tick, in either order, gets it once.
2910 // applies. The launch link belongs to getInitialURL once the page
2911 // has called it, whether native has dispatched it yet or not, so a
2912 // page that does both in the same tick, in either order, gets it
2913 // once. That includes a craftReady handler, which runs before
2914 // native dispatches anything. A page that asks getInitialURL only
2915 // later, after an await, should skip `initial` in onLink.
2916 //
2917 // The same block runs on Android (#215), where this script can run
2918 // twice in one document, so its state lives on window.
29132919 (function installDeepLinkReplay(craft) {
2914 var undelivered = [];
2915 var subscribed = false;
2916 window.addEventListener('craftDeepLink', function(e) {
2917 if (!subscribed) undelivered.push(e.detail);
2918 });
2920 var replay = window.__craftDeepLinkReplay;
2921 if (!replay) {
2922 replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
2923 window.addEventListener('craftDeepLink', function(e) {
2924 if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
2925 });
2926 }
2927 function claimed(detail) {
2928 return replay.initialClaimed && detail && detail.initial;
2929 }
29192930 craft._subscribeDeepLinks = function(callback) {
29202931 var active = true;
2921 var listener = function(e) { callback(e.detail); };
2932 var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
29222933 window.addEventListener('craftDeepLink', listener);
2923 if (!subscribed) {
2924 subscribed = true;
2934 if (!replay.subscribed) {
2935 replay.subscribed = true;
29252936 setTimeout(function() {
2926 var pending = undelivered;
2927 undelivered = [];
2937 var pending = replay.undelivered;
2938 replay.undelivered = [];
29282939 if (!active) return;
29292940 pending.forEach(function(detail) { callback(detail); });
29302941 }, 0);
@@ -2935,7 +2946,8 @@ struct CraftWebView: UIViewRepresentable {
29352946 };
29362947 };
29372948 craft._claimInitialDeepLink = function() {
2938 undelivered = undelivered.filter(function(detail) { return !(detail && detail.initial); });
2949 replay.initialClaimed = true;
2950 replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
29392951 };
29402952 })(window.craft);
29412953
packages/typescript/examples/basic-app.tsmodified+3-0
Changes to packages/typescript/examples/basic-app.ts
@@ -51,6 +51,9 @@ function setupEventListeners(): void {
5151 // Deep link handling
5252 if (craft.deepLinks) {
5353 craft.deepLinks.onLink((data: DeepLinkData) => {
54 // initializeFeatures() asks getInitialURL() for the launch link after
55 // other awaits, so it would arrive here too.
56 if (data.initial) return;
5457 console.log('Deep link received:', data.url);
5558 console.log('Host:', data.host);
5659 console.log('Path:', data.path);
packages/typescript/src/api/mobile.tsmodified+10-4
Changes to packages/typescript/src/api/mobile.ts
@@ -1286,18 +1286,24 @@ export const deepLinks = {
12861286 async getInitialURL(): Promise<string | null> {
12871287 return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.())
12881288 },
1289 onLink(callback: (url: string) => void): () => void {
1289 /**
1290 * The second argument says whether this is the link that launched the app.
1291 * A page that also calls `getInitialURL()` after an await gets that link
1292 * from both, and can skip it here when `initial` is true.
1293 */
1294 onLink(callback: (url: string, link: { initial: boolean }) => void): () => void {
12901295 const craft = getCraftRoot()
12911296 if (craft?.deepLinks?.onLink) {
12921297 return craft.deepLinks.onLink((value: unknown) => {
12931298 const url = normalizeDeepLinkURL(value)
1294 if (url) callback(url)
1299 if (url) callback(url, { initial: (value as { initial?: unknown } | null)?.initial === true })
12951300 }) ?? (() => {})
12961301 }
12971302 if (typeof globalThis === 'undefined') return () => {}
12981303 const listener = (event: Event) => {
1299 const url = normalizeDeepLinkURL((event as CustomEvent<unknown>).detail)
1300 if (url) callback(url)
1304 const detail = (event as CustomEvent<unknown>).detail
1305 const url = normalizeDeepLinkURL(detail)
1306 if (url) callback(url, { initial: (detail as { initial?: unknown } | null)?.initial === true })
13011307 }
13021308 globalThis.addEventListener('craftDeepLink', listener)
13031309 return () => globalThis.removeEventListener('craftDeepLink', listener)
packages/typescript/types/craft.d.tsmodified+9-7
Changes to packages/typescript/types/craft.d.ts
@@ -833,14 +833,16 @@ export interface CraftBridge {
833833 /**
834834 * Listen for deep links.
835835 *
836 * On iOS the first subscriber is also handed, once, every link that
837 * arrived before anything was subscribed, including the link that
838 * launched the app (`initial: true`). A page that calls `getInitialURL()`
839 * before subscribing, or in the same tick, gets the launch link from there
840 * instead and not a second time here. Android does not replay yet (#215).
836 * The first subscriber is also handed, once, every link that arrived
837 * before anything was subscribed, including the link that launched the
838 * app (`initial: true`). A page that calls `getInitialURL()` before
839 * subscribing, in the same tick, or in the same `craftReady` handler, gets
840 * the launch link from there instead and not a second time here. A page
841 * that calls `getInitialURL()` only later, after an await, gets it from
842 * both, and should skip `data.initial` here.
841843 *
842844 * @param callback - Called when a deep link is received
843 * @returns On iOS, a function that unsubscribes
845 * @returns A function that unsubscribes, on iOS and Android
844846 */
845847 onLink(callback: (data: DeepLinkData) => void): (() => void) | void;
846848 };
@@ -1025,7 +1027,7 @@ export interface DeepLinkData {
10251027 query: string;
10261028 /** Parsed query parameters */
10271029 queryParams?: Record<string, string>;
1028 /** True for the link that launched the app (iOS) */
1030 /** True for the link that launched the app */
10291031 initial?: boolean;
10301032}
10311033
packages/zig/src/android_dispatch.zigmodified+15-5
Changes to packages/zig/src/android_dispatch.zig
@@ -379,7 +379,7 @@ const natives = [_]jni.JNINativeMethod{
379379 .{ .name = "nativeResetDeepLinks", .signature = "()Z", .fnPtr = @ptrCast(&nativeResetDeepLinks) },
380380 .{ .name = "nativeSetInitialURL", .signature = "(Ljava/lang/String;)Z", .fnPtr = @ptrCast(&nativeSetInitialURL) },
381381 .{ .name = "nativeGetInitialURL", .signature = "()Z", .fnPtr = @ptrCast(&nativeGetInitialURL) },
382 .{ .name = "nativeDispatchDeepLink", .signature = "(Ljava/lang/String;)Z", .fnPtr = @ptrCast(&nativeDispatchDeepLink) },
382 .{ .name = "nativeDispatchDeepLink", .signature = "(Ljava/lang/String;Z)Z", .fnPtr = @ptrCast(&nativeDispatchDeepLink) },
383383 .{ .name = "nativeScreenshotReady", .signature = "([B)V", .fnPtr = @ptrCast(&nativeScreenshotReady) },
384384 .{ .name = "nativeScreenshotError", .signature = "(Ljava/lang/String;)V", .fnPtr = @ptrCast(&nativeScreenshotError) },
385385 .{ .name = "nativeTakeScreenshot", .signature = "(Landroid/app/Activity;Landroid/webkit/WebView;)Z", .fnPtr = @ptrCast(&nativeTakeScreenshot) },
@@ -1187,19 +1187,18 @@ fn nativeGetInitialURL(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jboolea
11871187 defer arena.deinit();
11881188 const allocator = arena.allocator();
11891189 const url = deeplink.snapshot(allocator) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE);
1190 const result = if (url) |text| deeplink.payload(j, allocator, text) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE) else "null";
1190 const result = if (url) |text| deeplink.payload(j, allocator, text, null) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE) else "null";
11911191 events.settle(allocator, deeplink.resolve_global, result) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE);
11921192 return jni.JNI_TRUE;
11931193}
11941194
1195fn nativeDispatchDeepLink(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring) callconv(.c) jni.jboolean {
1195fn nativeDispatchDeepLink(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring, initial: jni.jboolean) callconv(.c) jni.jboolean {
11961196 const j = Jni.init(env);
11971197 var arena = std.heap.ArenaAllocator.init(backing);
11981198 defer arena.deinit();
11991199 const allocator = arena.allocator();
12001200 const text = j.stringToUtf8(allocator, url) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1201 deeplink.rememberFirst(text) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1202 const detail = deeplink.payload(j, allocator, text) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1201 const detail = deeplink.payload(j, allocator, text, initial == jni.JNI_TRUE) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
12031202 events.emitEvent(allocator, deeplink.event_name, detail) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
12041203 return jni.JNI_TRUE;
12051204}
@@ -3143,6 +3142,17 @@ test "the registered natives name methods the Kotlin actually declares" {
31433142 "(Landroid/app/Activity;)Z",
31443143 std.mem.span(restore.signature),
31453144 );
3145
3146 // `CraftNative.nativeDispatchDeepLink(url: String, initial: Boolean)`. A
3147 // descriptor that disagrees fails RegisterNatives for all of them, which
3148 // only the emulator's runtime leg would show.
3149 const deep_link = for (natives) |native| {
3150 if (std.mem.eql(u8, std.mem.span(native.name), "nativeDispatchDeepLink")) break native;
3151 } else return error.TestExpectedEqual;
3152 try testing.expectEqualStrings(
3153 "(Ljava/lang/String;Z)Z",
3154 std.mem.span(deep_link.signature),
3155 );
31463156}
31473157
31483158test "the JavaVM table puts GetEnv where the header does" {
packages/zig/src/bridge_android_deeplink.zigmodified+19-16
Changes to packages/zig/src/bridge_android_deeplink.zig
@@ -49,18 +49,6 @@ pub fn set(url: ?[]const u8) !void {
4949 if (old) |bytes| std.heap.page_allocator.free(bytes);
5050}
5151
52pub fn rememberFirst(url: []const u8) !void {
53 const candidate = try std.heap.page_allocator.dupe(u8, url);
54 lock();
55 if (initial_url == null) {
56 initial_url = candidate;
57 unlock();
58 } else {
59 unlock();
60 std.heap.page_allocator.free(candidate);
61 }
62}
63
6452pub fn snapshot(allocator: std.mem.Allocator) !?[]u8 {
6553 lock();
6654 defer unlock();
@@ -81,7 +69,12 @@ fn stringOrEmpty(j: Jni, value: jobject) !jobject {
8169}
8270
8371/// Build the exact object both Kotlin paths built for one URL.
84pub fn payload(j: Jni, allocator: std.mem.Allocator, url: []const u8) ![]u8 {
72///
73/// `initial` is set for a dispatched `craftDeepLink` event, and says whether
74/// this link launched the app (#215), which the page uses to hand it to its
75/// first subscriber once. `getInitialURL`'s answer passes null and carries no
76/// such field, as on iOS.
77pub fn payload(j: Jni, allocator: std.mem.Allocator, url: []const u8, initial: ?bool) ![]u8 {
8578 try j.pushLocalFrame(32);
8679 defer _ = j.popLocalFrame(null);
8780
@@ -137,17 +130,27 @@ pub fn payload(j: Jni, allocator: std.mem.Allocator, url: []const u8) ![]u8 {
137130 }
138131 try jsonPut(j, root, "queryParams", query_params);
139132
133 if (initial) |flag| {
134 const put_result = try j.callObjectMethodA(
135 root,
136 try j.methodId(json_cls, "put", "(Ljava/lang/String;Z)Lorg/json/JSONObject;"),
137 &.{ .{ .l = try j.newStringUtf8(allocator, "initial") }, .{ .z = if (flag) jni.JNI_TRUE else jni.JNI_FALSE } },
138 );
139 j.deleteLocalRef(put_result);
140 }
141
140142 const rendered = try j.callObjectMethod(root, try j.methodId(json_cls, "toString", "()Ljava/lang/String;"));
141143 return j.stringToUtf8(allocator, rendered);
142144}
143145
144146const testing = std.testing;
145147
146test "reset, set and first-only storage preserve the bridge lifetime" {
148test "reset and set preserve the bridge lifetime" {
149 // Only Kotlin decides which link is the launch link now, so a dispatched
150 // link no longer becomes the initial URL here as a side effect (#215).
147151 reset();
148152 try testing.expect(try snapshot(testing.allocator) == null);
149 try rememberFirst("craft://first");
150 try rememberFirst("craft://second");
153 try set("craft://first");
151154 const first = (try snapshot(testing.allocator)).?;
152155 defer testing.allocator.free(first);
153156 try testing.expectEqualStrings("craft://first", first);
scripts/mobile-e2e/android.tsmodified+143-40
Changes to scripts/mobile-e2e/android.ts
@@ -2,7 +2,8 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { init } from '../../packages/android/src/index'
5import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol'
5import type { DeepLinkResult } from './protocol'
6import { androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol'
67import { command, driverPage, waitForFile } from './support'
78
89/**
@@ -19,10 +20,17 @@ import { command, driverPage, waitForFile } from './support'
1920 * Geolocation on, because that is what puts the location permissions in the
2021 * manifest, and `pm grant` refuses a permission the manifest does not declare.
2122 * The Kotlin does not otherwise read the flag (#209).
23 *
24 * Deep links on, with a scheme of the probe's own, for the cold starts through
25 * a link (#215). The generator refuses the flag without a scheme.
2226 */
27const DEEP_LINK_SCHEME = 'crafte2eprobe'
28
2329const CONFIG = {
2430 enablePushNotifications: false,
2531 enableGeolocation: true,
32 enableDeepLinks: true,
33 urlSchemes: [DEEP_LINK_SCHEME],
2634}
2735
2836/**
@@ -74,6 +82,11 @@ function legs(runtimeDir: string | null): Leg[] {
7482/** What JNI_OnLoad logs once it has bound the natives. */
7583const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/
7684
85/** Whether the page gave up before it could report anything. */
86function parseFatal(text: string): boolean {
87 return hasTerminated(text) && /"event":"fatal"/.test(text)
88}
89
7790async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) {
7891 return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure })
7992}
@@ -116,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
116129 copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html'))
117130 copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json'))
118131
132 // The intent filter the cold starts depend on, checked where it is written.
133 // `am start` below names the package, not the activity, so it resolves the
134 // link through this filter the way a browser would.
135 const manifest = readFileSync(join(project, 'app', 'src', 'main', 'AndroidManifest.xml'), 'utf8')
136 writeFileSync(join(evidence, 'AndroidManifest.xml'), manifest)
137 if (!manifest.includes(`android:scheme="${DEEP_LINK_SCHEME}"`))
138 failures.push(`the generated manifest declares no ${DEEP_LINK_SCHEME}:// intent filter, so no link can open the app`)
139
119140 // The generator writes gradle-wrapper.properties but never gradlew, so there
120141 // is no wrapper to invoke. Call Gradle directly, the way the templates gate
121142 // already does, and honour the same override so both use one toolchain.
@@ -173,47 +194,78 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
173194 if (runtimePermissionGranted(packageState, 'android.permission.ACCESS_FINE_LOCATION') === true)
174195 failures.push('ACCESS_FINE_LOCATION is granted too, so the location case cannot tell approximate-only from precise')
175196
176 // Not best-effort: the ring buffer outlives an uninstall, and a transcript
177 // left in it would otherwise be read as this run's. The plan event carries
178 // this run's nonce as a second guard.
179 await adb(['logcat', '-c'], { serial })
180 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
181 await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') })
182
183 // The share case needs its menu dismissed, and nothing on the device will
184 // do that: the harness plays the person. It acts only once the page has
185 // asked and the menu is really in front, and it keeps a screenshot of the
186 // menu, which is the evidence that there was one to dismiss.
187 let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked'
188
189 async function dismissShareMenuWhenAsked(log: string): Promise<void> {
190 if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return
191 shareMenu = 'asked'
192
193 const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true })
194 if (!shareMenuInFront(windows.stdout)) return
195
196 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'share-menu.png') })
197 await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') })
198 shareMenu = 'dismissed'
199 }
197 // One launch of the app: stopped first, then started with `start`, then
198 // read until `isDone`. The suite runs on every launch, the share case
199 // included, so every launch dismisses the menu when the page asks.
200 async function launch(prefix: string, start: string[], isDone: (text: string) => boolean) {
201 // Stopped and gone before the buffer is cleared, so nothing the previous
202 // launch logs late lands in this one's. Not best-effort: the ring buffer
203 // outlives an uninstall, and a transcript left in it would otherwise be
204 // read as this run's. The plan event carries this run's nonce as a
205 // second guard.
206 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
207 for (let attempt = 0; attempt < 20; attempt++) {
208 const running = await adb(['shell', 'pidof', PACKAGE], { serial, allowFailure: true })
209 if (!running.stdout.trim()) break
210 await Bun.sleep(500)
211 }
212 await adb(['logcat', '-c'], { serial })
213 const started = await adb(['shell', 'am', 'start', ...start], { serial, logPath: join(evidence, 'adb.log'), allowFailure: true })
214 const startedText = `${started.stdout}\n${started.stderr}`
215 // `am start` exits 0 for most refusals and prints `Error:` instead, so
216 // both are checked. Nothing to wait for when nothing started.
217 const refused = started.exitCode !== 0 || /^Error:/m.test(startedText)
218
219 // The share case needs its menu dismissed, and nothing on the device will
220 // do that: the harness plays the person. It acts only once the page has
221 // asked and the menu is really in front, and it keeps a screenshot of the
222 // menu, which is the evidence that there was one to dismiss.
223 let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked'
224
225 async function dismissShareMenuWhenAsked(log: string): Promise<void> {
226 if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return
227 shareMenu = 'asked'
228
229 const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true })
230 if (!shareMenuInFront(windows.stdout)) return
231
232 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, `${prefix}share-menu.png`) })
233 await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') })
234 shareMenu = 'dismissed'
235 }
200236
201 // logcat -d dumps and exits, so poll it into the evidence file rather than
202 // streaming: a stream would have to be killed at exactly the right moment,
203 // and the dump is cheap.
204 const logPath = join(evidence, 'logcat.txt')
205 const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => {
206 const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true })
207 writeFileSync(logPath, dumped.stdout)
208 await dismissShareMenuWhenAsked(dumped.stdout)
209 })
237 // logcat -d dumps and exits, so poll it into the evidence file rather than
238 // streaming: a stream would have to be killed at exactly the right moment,
239 // and the dump is cheap.
240 const logPath = join(evidence, `${prefix}logcat.txt`)
241 const finished = !refused && await waitForFile(logPath, options.timeoutMs, isDone, async () => {
242 const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true })
243 writeFileSync(logPath, dumped.stdout)
244 await dismissShareMenuWhenAsked(dumped.stdout)
245 })
246
247 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, `${prefix}screen.png`) })
248 const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true })
249 writeFileSync(join(evidence, `${prefix}logcat-full.txt`), full.stdout)
250 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
251
252 return {
253 started: startedText,
254 refused,
255 finished,
256 shareMenu,
257 log: existsSync(logPath) ? readFileSync(logPath, 'utf8') : '',
258 full: full.stdout,
259 }
260 }
210261
211 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') })
212 const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true })
213 writeFileSync(join(evidence, 'logcat-full.txt'), full.stdout)
214 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
262 const main = await launch('', ['-n', `${PACKAGE}/.MainActivity`], hasTerminated)
263 if (main.refused)
264 throw new Error(`am start refused to launch the app: ${main.started.trim()}`)
265 const finished = main.finished
266 const shareMenu = main.shareMenu
215267
216 const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : ''
268 const logText = main.log
217269 if (!finished)
218270 failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`)
219271
@@ -249,6 +301,55 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
249301 failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`)
250302 }
251303
304 // #215: cold-start the app through a link, twice, and judge what the page
305 // received, with the same judgement the iOS legs use. Through
306 // `am start -a VIEW -d`, which Android resolves through the manifest's
307 // intent filter with no prompt; the package names which app, not which
308 // activity, so the filter is what is tested. Quoted, because `adb shell`
309 // hands the device shell one string and `&` would end the command there.
310 const coldLink = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}`
311 const coldStarts: DeepLinkResult[] = []
312 const coldLogs: string[] = []
313 // Modes that already failed for a reason of their own, so the verdict below
314 // does not also call them "never reported".
315 const coldFailed = new Set<DeepLinkResult['receive']>()
316 for (const receive of ['subscribe', 'both'] as const) {
317 const link = `${coldLink}&receive=${receive}`
318 const prefix = `deeplink-${receive}-`
319 const cold = await launch(
320 prefix,
321 ['-W', '-a', 'android.intent.action.VIEW', '-c', 'android.intent.category.BROWSABLE', '-d', `'${link}'`, PACKAGE],
322 text => deepLinkReports(text).length > 0 || parseFatal(text),
323 )
324 coldLogs.push(cold.full)
325 writeFileSync(join(evidence, `${prefix}am-start.txt`), cold.started)
326
327 // `am start -W` says how the activity started. Anything but a cold launch
328 // means the link went to a running app through onNewIntent, which is the
329 // warm path, not the one under test.
330 if (cold.refused || !/Status: ok/.test(cold.started) || !/LaunchState: COLD/.test(cold.started)) {
331 failures.push(`the ${receive} cold start did not launch the app cold: ${cold.started.trim().split('\n').slice(-3).join(' / ')}`)
332 coldFailed.add(receive)
333 continue
334 }
335
336 const reports = deepLinkReports(cold.log)
337 if (reports.length > 1) {
338 failures.push(`the ${receive} cold start reported ${reports.length} different results; see ${label}/${prefix}logcat.txt`)
339 coldFailed.add(receive)
340 }
341 else if (reports.length === 1)
342 coldStarts.push({ ...reports[0]!, receive, link })
343
344 const coldRegistered = REGISTERED.test(cold.log)
345 if (leg.requireZig && !coldRegistered)
346 failures.push(`the Zig runtime did not register its natives on the ${receive} cold start; see ${label}/${prefix}logcat.txt`)
347 if (!leg.requireZig && coldRegistered)
348 failures.push(`the shim leg registered natives on the ${receive} cold start; it was generated with no runtime`)
349 }
350 failures.push(...deepLinkProblems(coldStarts, coldLink, receive => `${label}/deeplink-${receive}-logcat.txt`)
351 .filter(problem => ![...coldFailed].some(receive => problem.startsWith(`the ${receive} cold start never reported`))))
352
252353 // Did the Zig runtime load, bind — and then actually answer? JNI_OnLoad
253354 // reports the first two on the happy path as well as the failure ones, and
254355 // every way a native can give up now says so too (android_dispatch.zig's
@@ -265,7 +366,9 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
265366 // the part worth quoting back.
266367 const registered = logText.match(REGISTERED)
267368 const fullLog = existsSync(join(evidence, 'logcat-full.txt')) ? readFileSync(join(evidence, 'logcat-full.txt'), 'utf8') : logText
268 const declines = androidDeclines(fullLog)
369 // The cold starts too: a link Zig failed to dispatch falls through to
370 // Kotlin, and the page could not tell.
371 const declines = [fullLog, ...coldLogs].flatMap(text => androidDeclines(text))
269372 const zigActions: string[] = []
270373
271374 if (leg.requireZig) {
scripts/mobile-e2e/driver.htmlmodified+26-10
Changes to scripts/mobile-e2e/driver.html
@@ -375,9 +375,11 @@
375375 //
376376 // Runs after the suite, so the subscription is seconds after the bridge
377377 // appeared, the way an app that hydrates before wiring its router
378 // subscribes. The harness cold-starts the app through a link from an
379 // XCUITest, which reads this paragraph back. The app's console is not
380 // captured on that launch; SpringBoard started it, not simctl.
378 // subscribes. On iOS the harness cold-starts the app through a link from
379 // an XCUITest, which reads this paragraph back: the app's console is not
380 // captured on that launch, because SpringBoard started it, not simctl. On
381 // Android (#215) `am start` opens the link and the harness reads the same
382 // line from logcat.
381383 //
382384 // The link says which way to receive it. `receive=subscribe` only
383385 // subscribes, and must get the launch link exactly once. `receive=both`
@@ -387,16 +389,30 @@
387389 function probeDeepLinks() {
388390 var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null
389391 var receive = launch && launch.url.indexOf('receive=both') !== -1 ? 'both' : 'subscribe'
390 var seen = { launch: launch ? launch.url : null, receive: receive, onLink: [], initialURL: null }
392 var seen = { launch: launch ? launch.url : null, receive: receive, onLink: [], initialURL: null, unsubscribe: null }
393 var answered = receive !== 'both'
391394 if (receive === 'both') {
392 window.craft.deepLinks.getInitialURL().then(function (value) { seen.initialURL = value && value.url })
395 window.craft.deepLinks.getInitialURL().then(
396 function (value) { seen.initialURL = value && value.url; answered = true },
397 function () { answered = true }
398 )
393399 }
394 window.craft.deepLinks.onLink(function (detail) {
400 var unsubscribe = window.craft.deepLinks.onLink(function (detail) {
395401 seen.onLink.push({ url: detail && detail.url, initial: detail && detail.initial })
396402 })
397 setTimeout(function () {
398 document.getElementById('deeplink').textContent = 'CRAFT-E2E-DEEPLINK ' + JSON.stringify(seen)
399 }, 1500)
403 seen.unsubscribe = typeof unsubscribe
404 // Long enough for a replay, or a second delivery, to have arrived, and
405 // for getInitialURL to answer on a slow emulator.
406 var waited = 0
407 function report() {
408 waited += 250
409 if ((!answered || waited < 1500) && waited < 6000) { setTimeout(report, 250); return }
410 var line = 'CRAFT-E2E-DEEPLINK ' + JSON.stringify(seen)
411 document.getElementById('deeplink').textContent = line
412 try { console.log(line) } catch (ignored) {}
413 try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {}
414 }
415 setTimeout(report, 250)
400416 }
401417
402418 function waitForBridge(attempt) {
@@ -428,7 +444,7 @@
428444 if (index >= selected.length) {
429445 emit({ event: 'done', passed: passed, failed: failed })
430446 document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED'
431 if (platform === 'ios') probeDeepLinks()
447 probeDeepLinks()
432448 return
433449 }
434450
scripts/mobile-e2e/protocol.test.tsmodified+41-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -373,6 +373,46 @@ describe('cold-start deep links', () => {
373373 'the both cold start never reported; see xcodebuild-deeplink.log',
374374 ])
375375 })
376
377 it('fails an onLink that returns no way to unsubscribe, and ignores a report that predates the field', () => {
378 const noUnsubscribe = line('subscribe', { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null, unsubscribe: 'undefined' })
379 expect(deepLinkProblems(deepLinkResults(`${noUnsubscribe}\n${passing.split('\n')[2]}`), LINK))
380 .toEqual(['onLink returned undefined in the subscribe cold start, expected a function that unsubscribes'])
381 expect(deepLinkProblems(deepLinkResults(passing), LINK)).toEqual([])
382 })
383})
384
385describe('cold-start deep links on Android', () => {
386 const LINK = 'crafte2eprobe://e2e/cold?run=craft-e2e-android-shim-1'
387 const report = { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null, unsubscribe: 'function' }
388 const json = JSON.stringify(report)
389
390 it('reads the report once, from either log channel', () => {
391 // console.log reaches logcat under chromium, quoted, with its source; the
392 // same line through craft.log arrives under the bridge's tag.
393 const text = [
394 `09-17 11:29:25.000 2611 2611 I chromium: [INFO:CONSOLE(1)] "CRAFT-E2E-DEEPLINK ${json}", source: https://appassets.androidplatform.net/ (1)`,
395 `09-17 11:29:25.001 2611 2611 D CraftBridge: CRAFT-E2E-DEEPLINK ${json}`,
396 ].join('\n')
397
398 expect(deepLinkReports(text)).toEqual([report])
399 })
400
401 it('keeps two different reports apart, so the runner can refuse them', () => {
402 const other = JSON.stringify({ ...report, onLink: [] })
403 expect(deepLinkReports(`D CraftBridge: CRAFT-E2E-DEEPLINK ${json}\nD CraftBridge: CRAFT-E2E-DEEPLINK ${other}`)).toHaveLength(2)
404 })
405
406 it('is not mistaken for a driver event, or a malformed one, by the run verdict', () => {
407 const lines = parseDriverOutput(`D CraftBridge: CRAFT-E2E-DEEPLINK ${json}`)
408 expect(lines.events).toEqual([])
409 expect(lines.malformed).toEqual([])
410 })
411
412 it('names the logcat file for a cold start that never reported', () => {
413 expect(deepLinkProblems([{ ...report, receive: 'subscribe', link: `${LINK}&receive=subscribe` }], LINK, receive => `android-shim/deeplink-${receive}-logcat.txt`))
414 .toEqual(['the both cold start never reported; see android-shim/deeplink-both-logcat.txt'])
415 })
376416})
377417
378418/** A minimal ELF64 little-endian file whose section header table names `sections`. */
scripts/mobile-e2e/protocol.tsmodified+41-3
Changes to scripts/mobile-e2e/protocol.ts
@@ -325,13 +325,45 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?
325325 return { ok: failures.length === 0, failures, planned, passed, failed }
326326}
327327
328/** What one cold start through a link saw, as the XCUITest printed it. */
328/** What one cold start through a link saw, as the page reported it. */
329329export interface DeepLinkResult {
330330 receive: 'subscribe' | 'both'
331331 link: string
332332 launch: string | null
333333 onLink: { url?: string, initial?: boolean }[]
334334 initialURL: string | null
335 /** `typeof` what onLink returned. Absent from reports older than #215. */
336 unsubscribe?: string
337}
338
339/** The report the page writes, without the label the harness adds. */
340export type DeepLinkReport = Omit<DeepLinkResult, 'receive' | 'link'>
341
342/**
343 * The page's deep-link reports in an Android log, one per distinct report.
344 *
345 * On Android the page logs its `CRAFT-E2E-DEEPLINK {json}` line through both
346 * console.log, which logcat carries under `chromium` wrapped in quotes and a
347 * source suffix, and craft.log, under the bridge's own tag. The same report
348 * arriving twice is one report; two different ones means the page reported
349 * twice, which the caller treats as a failure.
350 */
351export function deepLinkReports(text: string): DeepLinkReport[] {
352 const reports: DeepLinkReport[] = []
353 const seen = new Set<string>()
354 for (const line of text.replace(ANSI, '').split('\n')) {
355 const at = line.indexOf('CRAFT-E2E-DEEPLINK ')
356 if (at === -1 || line.includes('CRAFT-E2E-DEEPLINK-RESULT')) continue
357 const json = firstJsonObject(line.slice(at + 'CRAFT-E2E-DEEPLINK '.length))
358 if (!json || seen.has(json)) continue
359 seen.add(json)
360 try {
361 const report = JSON.parse(json) as DeepLinkReport
362 if (Array.isArray(report.onLink)) reports.push(report)
363 }
364 catch {}
365 }
366 return reports
335367}
336368
337369/**
@@ -364,15 +396,21 @@ export function deepLinkResults(output: string): DeepLinkResult[] {
364396 * `both` is the guard on the fix: a page that also calls getInitialURL must
365397 * get the link from there and not a second time from onLink.
366398 */
367export function deepLinkProblems(results: DeepLinkResult[], link: string): string[] {
399export function deepLinkProblems(
400 results: DeepLinkResult[],
401 link: string,
402 evidence: (receive: DeepLinkResult['receive']) => string = () => 'xcodebuild-deeplink.log',
403): string[] {
368404 const problems: string[] = []
369405 for (const receive of ['subscribe', 'both'] as const) {
370406 const result = results.find(entry => entry.receive === receive)
371407 const expectedLink = `${link}&receive=${receive}`
372408 if (!result) {
373 problems.push(`the ${receive} cold start never reported; see xcodebuild-deeplink.log`)
409 problems.push(`the ${receive} cold start never reported; see ${evidence(receive)}`)
374410 continue
375411 }
412 if (result.unsubscribe !== undefined && result.unsubscribe !== 'function')
413 problems.push(`onLink returned ${result.unsubscribe} in the ${receive} cold start, expected a function that unsubscribes`)
376414 if (result.launch !== expectedLink) {
377415 problems.push(`the ${receive} cold start was not launched by its link: the page saw ${JSON.stringify(result.launch)}`)
378416 continue