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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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/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);