also looking at this
fix(android): hand the launch link to the page that subscribes for it
#232
17 files
+776
-136
| @@ -76,6 +76,14 @@ that part. It waits until `dumpsys window` shows the chooser holding input | ||
| 76 | 76 | focus, saves `share-menu.png`, presses Back, and fails the run if the page asked |
| 77 | 77 | for a dismissal and no menu ever appeared. |
| 78 | 78 | |
| 79 | After the suite, each leg also cold-starts the app through a link twice, the | |
| 80 | same 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 | |
| 82 | link is resolved through the generated manifest's intent filter, and `-W` must | |
| 83 | report `LaunchState: COLD`, so a link delivered to an app that was still | |
| 84 | running cannot pass for one that launched it. The page writes its report to | |
| 85 | logcat, and each launch keeps its own `deeplink-<mode>-logcat.txt`. | |
| 86 | ||
| 79 | 87 | The runtime leg fails on any line where a Zig native gave up — "fell through |
| 80 | 88 | to the shim", "failed with no fallback" or "could not reach the page" — because |
| 81 | 89 | Kotlin answering in its place is invisible to the page, and a runtime that has |
| @@ -327,6 +327,38 @@ describe('Craft Android builder', () => { | ||
| 327 | 327 | expect(bridge).toContain('craft.ar.onPlaneDetected is unavailable because ARCore requires native Activity integration') |
| 328 | 328 | }) |
| 329 | 329 | |
| 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 | ||
| 330 | 362 | it('routes external Activity results back to every pending media promise', async () => { |
| 331 | 363 | const output = mkdtempSync(join(tmpdir(), 'craft-android-activity-results-')) |
| 332 | 364 | await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output }) |
| @@ -0,0 +1,217 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { describe, expect, it } from 'bun:test' | |
| 4 | import { 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 | ||
| 10 | const template = readFileSync(join(import.meta.dir, '../templates/CraftBridge.kt.template'), 'utf8') | |
| 11 | ||
| 12 | /** The page script, as Kotlin evaluates it, with every flag off. */ | |
| 13 | function 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 | ||
| 29 | type 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. */ | |
| 32 | function 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 | ||
| 91 | const tick = () => new Promise(resolve => setTimeout(resolve, 5)) | |
| 92 | ||
| 93 | describe('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 | }) | |
| @@ -221,6 +221,14 @@ class CraftBridge( | ||
| 221 | 221 | private var bridgeReady = false |
| 222 | 222 | private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>() |
| 223 | 223 | |
| 224 | // Deep links (#215), on the UI thread only, the way iOS's DeepLinkManager | |
| 225 | // holds them: a link that arrives before the first page has its bridge is | |
| 226 | // the launch link, and every link waits for an injected bridge. | |
| 227 | private var hasBeenReady = false | |
| 228 | private var initialAssigned = false | |
| 229 | private var launchURL: String? = null | |
| 230 | private val pendingDeepLinks = mutableListOf<Pair<String, Boolean>>() | |
| 231 | ||
| 224 | 232 | // Location tracking |
| 225 | 233 | private var fusedLocationClient: FusedLocationProviderClient? = null |
| 226 | 234 | private val locationCallbacks = mutableMapOf<Int, LocationCallback>() |
| @@ -310,6 +318,10 @@ class CraftBridge( | ||
| 310 | 318 | // still loading has somewhere to go rather than being counted as |
| 311 | 319 | // dropped. |
| 312 | 320 | installNativeDeliverer() |
| 321 | // A page is about to exist, so no later link launched the app, even | |
| 322 | // one that arrives before this script's completion runs. iOS marks | |
| 323 | // the same moment. | |
| 324 | hasBeenReady = true | |
| 313 | 325 | |
| 314 | 326 | val script = """ |
| 315 | 327 | |
| @@ -598,7 +610,7 @@ class CraftBridge( | ||
| 598 | 610 | |
| 599 | 611 | // Deep Links |
| 600 | 612 | onDeepLink: function(callback) { |
| 601 | window.addEventListener('craftDeepLink', function(e) { callback(e.detail); }); | |
| 613 | return window.craft._subscribeDeepLinks(callback); | |
| 602 | 614 | }, |
| 603 | 615 | |
| 604 | 616 | // QR/Barcode Scanner |
| @@ -999,12 +1011,13 @@ class CraftBridge( | ||
| 999 | 1011 | // Deep Links |
| 1000 | 1012 | deepLinks: { |
| 1001 | 1013 | getInitialURL: function() { |
| 1014 | window.craft._claimInitialDeepLink(); | |
| 1002 | 1015 | return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() { |
| 1003 | 1016 | CraftAndroid.getInitialURL(); |
| 1004 | 1017 | }); |
| 1005 | 1018 | }, |
| 1006 | 1019 | onLink: function(callback) { |
| 1007 | window.addEventListener('craftDeepLink', function(e) { callback(e.detail); }); | |
| 1020 | return window.craft._subscribeDeepLinks(callback); | |
| 1008 | 1021 | } |
| 1009 | 1022 | }, |
| 1010 | 1023 | |
| @@ -1354,6 +1367,62 @@ class CraftBridge( | ||
| 1354 | 1367 | } |
| 1355 | 1368 | }; |
| 1356 | 1369 | |
| 1370 | // Links that arrive before anything is listening (#215, as iOS | |
| 1371 | // does since #198). | |
| 1372 | // | |
| 1373 | // Native dispatches a link once this script has run, and on a cold | |
| 1374 | // start that is the link the app was opened with. Only a craftReady | |
| 1375 | // handler can have called onLink by then, since onLink is defined | |
| 1376 | // by this very script. So such a link used to reach nobody, and a | |
| 1377 | // page that subscribes later, rather than asking getInitialURL, | |
| 1378 | // never learned how it was opened. | |
| 1379 | // | |
| 1380 | // Held here instead, and handed to the first subscriber on the | |
| 1381 | // next turn, so an unsubscribe returned in the same tick still | |
| 1382 | // applies. The launch link belongs to getInitialURL once the page | |
| 1383 | // has called it, whether native has dispatched it yet or not, so a | |
| 1384 | // page that does both in the same tick or in a craftReady handler, | |
| 1385 | // in either order, gets it once. A page that asks getInitialURL | |
| 1386 | // only later, after an await, should skip `initial` in onLink. | |
| 1387 | // | |
| 1388 | // The state lives on window, not in this function, because this | |
| 1389 | // script can run twice in one document, and a second copy must | |
| 1390 | // neither reset it nor buffer every link a second time. | |
| 1391 | (function installDeepLinkReplay(craft) { | |
| 1392 | var replay = window.__craftDeepLinkReplay; | |
| 1393 | if (!replay) { | |
| 1394 | replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false}; | |
| 1395 | window.addEventListener('craftDeepLink', function(e) { | |
| 1396 | if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail); | |
| 1397 | }); | |
| 1398 | } | |
| 1399 | function claimed(detail) { | |
| 1400 | return replay.initialClaimed && detail && detail.initial; | |
| 1401 | } | |
| 1402 | craft._subscribeDeepLinks = function(callback) { | |
| 1403 | var active = true; | |
| 1404 | var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); }; | |
| 1405 | window.addEventListener('craftDeepLink', listener); | |
| 1406 | if (!replay.subscribed) { | |
| 1407 | replay.subscribed = true; | |
| 1408 | setTimeout(function() { | |
| 1409 | var pending = replay.undelivered; | |
| 1410 | replay.undelivered = []; | |
| 1411 | if (!active) return; | |
| 1412 | pending.forEach(function(detail) { callback(detail); }); | |
| 1413 | }, 0); | |
| 1414 | } | |
| 1415 | return function() { | |
| 1416 | active = false; | |
| 1417 | window.removeEventListener('craftDeepLink', listener); | |
| 1418 | }; | |
| 1419 | }; | |
| 1420 | craft._claimInitialDeepLink = function() { | |
| 1421 | replay.initialClaimed = true; | |
| 1422 | replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); }); | |
| 1423 | }; | |
| 1424 | })(window.craft); | |
| 1425 | ||
| 1357 | 1426 | (function installCraftMobileContract(craft) { |
| 1358 | 1427 | var legacyShare = craft.share.bind(craft); |
| 1359 | 1428 | var legacyOpenCamera = craft.openCamera.bind(craft); |
| @@ -1485,6 +1554,11 @@ class CraftBridge( | ||
| 1485 | 1554 | val events = pendingEvents.toList() |
| 1486 | 1555 | pendingEvents.clear() |
| 1487 | 1556 | events.forEach { (name, data) -> sendEvent(name, data) } |
| 1557 | // After the script, so the page's replay listener exists and | |
| 1558 | // the native deliverer is installed. | |
| 1559 | val links = pendingDeepLinks.toList() | |
| 1560 | pendingDeepLinks.clear() | |
| 1561 | links.forEach { (url, initial) -> dispatchDeepLink(url, initial) } | |
| 1488 | 1562 | } |
| 1489 | 1563 | } |
| 1490 | 1564 | } |
| @@ -3314,18 +3388,6 @@ class CraftBridge( | ||
| 3314 | 3388 | return true |
| 3315 | 3389 | } |
| 3316 | 3390 | |
| 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 | ||
| 3329 | 3391 | // ==================== QR/Barcode Scanner ==================== |
| 3330 | 3392 | |
| 3331 | 3393 | @JavascriptInterface |
| @@ -3889,6 +3951,7 @@ class CraftBridge( | ||
| 3889 | 3951 | isKeepingAwake = false |
| 3890 | 3952 | pendingPermissionRequests.clear() |
| 3891 | 3953 | pendingEvents.clear() |
| 3954 | pendingDeepLinks.clear() | |
| 3892 | 3955 | } |
| 3893 | 3956 | |
| 3894 | 3957 | // ==================== Screen Capture ==================== |
| @@ -4662,13 +4725,44 @@ class CraftBridge( | ||
| 4662 | 4725 | } |
| 4663 | 4726 | } |
| 4664 | 4727 | |
| 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) | |
| 4671 | 4740 | } |
| 4741 | if (bridgeReady) dispatchDeepLink(url, false) else pendingDeepLinks.add(url to initial) | |
| 4742 | } | |
| 4743 | ||
| 4744 | fun saveDeepLinks(outState: Bundle) { | |
| 4745 | outState.putBoolean(STATE_LAUNCH_ASSIGNED, initialAssigned) | |
| 4746 | outState.putBoolean(STATE_PAGE_WAS_READY, hasBeenReady) | |
| 4747 | outState.putString(STATE_LAUNCH_URL, launchURL) | |
| 4748 | } | |
| 4749 | ||
| 4750 | /** | |
| 4751 | * A recreated activity loads a new WebView and a fresh document, so the | |
| 4752 | * launch link is queued again for that document's first subscriber, the | |
| 4753 | * way it was for the first one; a page that called getInitialURL claims | |
| 4754 | * it as before. Links that arrive now are never the launch link. | |
| 4755 | */ | |
| 4756 | fun restoreDeepLinks(savedState: Bundle) { | |
| 4757 | initialAssigned = savedState.getBoolean(STATE_LAUNCH_ASSIGNED, true) | |
| 4758 | hasBeenReady = savedState.getBoolean(STATE_PAGE_WAS_READY, true) | |
| 4759 | launchURL = savedState.getString(STATE_LAUNCH_URL) | |
| 4760 | setInitialURL(launchURL) | |
| 4761 | launchURL?.let { pendingDeepLinks.add(it to true) } | |
| 4762 | } | |
| 4763 | ||
| 4764 | private fun dispatchDeepLink(url: String, initial: Boolean) { | |
| 4765 | if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return | |
| 4672 | 4766 | |
| 4673 | 4767 | val uri = Uri.parse(url) |
| 4674 | 4768 | val json = JSONObject().apply { |
| @@ -4683,6 +4777,7 @@ class CraftBridge( | ||
| 4683 | 4777 | queryParams.put(name, uri.getQueryParameter(name) ?: "") |
| 4684 | 4778 | } |
| 4685 | 4779 | put("queryParams", queryParams) |
| 4780 | put("initial", initial) | |
| 4686 | 4781 | } |
| 4687 | 4782 | |
| 4688 | 4783 | evaluateJavascriptUnlessClosed( |
| @@ -4753,6 +4848,9 @@ class CraftBridge( | ||
| 4753 | 4848 | } |
| 4754 | 4849 | |
| 4755 | 4850 | companion object { |
| 4851 | private const val STATE_LAUNCH_ASSIGNED = "craft.deepLinks.launchAssigned" | |
| 4852 | private const val STATE_PAGE_WAS_READY = "craft.deepLinks.pageWasReady" | |
| 4853 | private const val STATE_LAUNCH_URL = "craft.deepLinks.launchURL" | |
| 4756 | 4854 | private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$") |
| 4757 | 4855 | const val REQUEST_CAMERA = 1001 |
| 4758 | 4856 | const val REQUEST_GALLERY = 1002 |
| @@ -250,7 +250,7 @@ object CraftNative { | ||
| 250 | 250 | private external fun nativeResetDeepLinks(): Boolean |
| 251 | 251 | private external fun nativeSetInitialURL(url: String?): Boolean |
| 252 | 252 | private external fun nativeGetInitialURL(): Boolean |
| 253 | private external fun nativeDispatchDeepLink(url: String): Boolean | |
| 253 | private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean | |
| 254 | 254 | private external fun nativeScreenshotReady(bytes: ByteArray) |
| 255 | 255 | private external fun nativeScreenshotError(message: String) |
| 256 | 256 | private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean |
| @@ -716,9 +716,9 @@ object CraftNative { | ||
| 716 | 716 | return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false } |
| 717 | 717 | } |
| 718 | 718 | |
| 719 | fun dispatchDeepLink(url: String): Boolean { | |
| 719 | fun dispatchDeepLink(url: String, initial: Boolean): Boolean { | |
| 720 | 720 | if (!isAvailable) return false |
| 721 | return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false } | |
| 721 | return try { nativeDispatchDeepLink(url, initial) } catch (e: UnsatisfiedLinkError) { false } | |
| 722 | 722 | } |
| 723 | 723 | |
| 724 | 724 | @JvmStatic |
| @@ -44,7 +44,19 @@ class MainActivity : AppCompatActivity() { | ||
| 44 | 44 | |
| 45 | 45 | webView = findViewById(R.id.webview) |
| 46 | 46 | 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 | } | |
| 48 | 60 | if (savedInstanceState == null) handleIncomingShortcut(intent) |
| 49 | 61 | |
| 50 | 62 | setupWebView() |
| @@ -150,12 +162,6 @@ class MainActivity : AppCompatActivity() { | ||
| 150 | 162 | } |
| 151 | 163 | } |
| 152 | 164 | |
| 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 | ||
| 159 | 165 | private fun handleIncomingShortcut(source: Intent?) { |
| 160 | 166 | source?.getStringExtra(SHORTCUT_TYPE_EXTRA)?.let(craftBridge::dispatchShortcut) |
| 161 | 167 | } |
| @@ -198,10 +204,15 @@ class MainActivity : AppCompatActivity() { | ||
| 198 | 204 | return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins |
| 199 | 205 | } |
| 200 | 206 | |
| 207 | override fun onSaveInstanceState(outState: Bundle) { | |
| 208 | super.onSaveInstanceState(outState) | |
| 209 | craftBridge.saveDeepLinks(outState) | |
| 210 | } | |
| 211 | ||
| 201 | 212 | override fun onNewIntent(intent: Intent) { |
| 202 | 213 | super.onNewIntent(intent) |
| 203 | 214 | setIntent(intent) |
| 204 | handleIncomingDeepLink(intent, dispatch = true) | |
| 215 | intent.data?.toString()?.let(craftBridge::receiveDeepLink) | |
| 205 | 216 | handleIncomingShortcut(intent) |
| 206 | 217 | } |
| 207 | 218 | |
| @@ -23,17 +23,22 @@ function pageScript(): string { | ||
| 23 | 23 | |
| 24 | 24 | interface Post { action: string, callbackId?: string, [key: string]: unknown } |
| 25 | 25 | |
| 26 | function loadPage() { | |
| 26 | type 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. */ | |
| 29 | function loadPage(beforeInject?: (page: Record<string, any>) => void) { | |
| 27 | 30 | const posts: Post[] = [] |
| 28 | const listeners: Record<string, ((event: { type: string, detail: unknown }) => void)[]> = {} | |
| 31 | const listeners: Record<string, Listener[]> = {} | |
| 29 | 32 | const page: Record<string, any> = { |
| 30 | 33 | 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) => { | |
| 32 | 35 | (listeners[type] ||= []).push(listener) |
| 33 | 36 | }, |
| 34 | removeEventListener: () => {}, | |
| 37 | removeEventListener: (type: string, listener: Listener) => { | |
| 38 | listeners[type] = (listeners[type] || []).filter(existing => existing !== listener) | |
| 39 | }, | |
| 35 | 40 | 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) | |
| 37 | 42 | return true |
| 38 | 43 | }, |
| 39 | 44 | location: { href: 'craft://app/' }, |
| @@ -47,6 +52,7 @@ function loadPage() { | ||
| 47 | 52 | } |
| 48 | 53 | } |
| 49 | 54 | const quiet = { log() {}, warn() {}, error() {} } |
| 55 | beforeInject?.(page) | |
| 50 | 56 | // eslint-disable-next-line no-new-func |
| 51 | 57 | new Function('window', 'document', 'navigator', 'CustomEvent', 'console', pageScript())( |
| 52 | 58 | page, |
| @@ -69,6 +75,9 @@ function loadPage() { | ||
| 69 | 75 | // What Swift's resolveCallback and rejectCallback evaluate. |
| 70 | 76 | answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value), |
| 71 | 77 | 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 } })), | |
| 72 | 81 | } |
| 73 | 82 | } |
| 74 | 83 | |
| @@ -144,4 +153,36 @@ describe('the injected iOS page script', () => { | ||
| 144 | 153 | await expect(settled).rejects.toMatchObject({ code: 'NATIVE_CALL_FAILED' }) |
| 145 | 154 | }) |
| 146 | 155 | } |
| 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 | }) | |
| 147 | 188 | }) |
| @@ -2907,24 +2907,35 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2907 | 2907 | // |
| 2908 | 2908 | // Held here instead, and handed to the first subscriber on the |
| 2909 | 2909 | // 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. | |
| 2913 | 2919 | (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 | } | |
| 2919 | 2930 | craft._subscribeDeepLinks = function(callback) { |
| 2920 | 2931 | var active = true; |
| 2921 | var listener = function(e) { callback(e.detail); }; | |
| 2932 | var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); }; | |
| 2922 | 2933 | window.addEventListener('craftDeepLink', listener); |
| 2923 | if (!subscribed) { | |
| 2924 | subscribed = true; | |
| 2934 | if (!replay.subscribed) { | |
| 2935 | replay.subscribed = true; | |
| 2925 | 2936 | setTimeout(function() { |
| 2926 | var pending = undelivered; | |
| 2927 | undelivered = []; | |
| 2937 | var pending = replay.undelivered; | |
| 2938 | replay.undelivered = []; | |
| 2928 | 2939 | if (!active) return; |
| 2929 | 2940 | pending.forEach(function(detail) { callback(detail); }); |
| 2930 | 2941 | }, 0); |
| @@ -2935,7 +2946,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2935 | 2946 | }; |
| 2936 | 2947 | }; |
| 2937 | 2948 | 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); }); | |
| 2939 | 2951 | }; |
| 2940 | 2952 | })(window.craft); |
| 2941 | 2953 | |
| @@ -51,6 +51,9 @@ function setupEventListeners(): void { | ||
| 51 | 51 | // Deep link handling |
| 52 | 52 | if (craft.deepLinks) { |
| 53 | 53 | 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; | |
| 54 | 57 | console.log('Deep link received:', data.url); |
| 55 | 58 | console.log('Host:', data.host); |
| 56 | 59 | console.log('Path:', data.path); |
| @@ -1286,18 +1286,24 @@ export const deepLinks = { | ||
| 1286 | 1286 | async getInitialURL(): Promise<string | null> { |
| 1287 | 1287 | return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.()) |
| 1288 | 1288 | }, |
| 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 { | |
| 1290 | 1295 | const craft = getCraftRoot() |
| 1291 | 1296 | if (craft?.deepLinks?.onLink) { |
| 1292 | 1297 | return craft.deepLinks.onLink((value: unknown) => { |
| 1293 | 1298 | const url = normalizeDeepLinkURL(value) |
| 1294 | if (url) callback(url) | |
| 1299 | if (url) callback(url, { initial: (value as { initial?: unknown } | null)?.initial === true }) | |
| 1295 | 1300 | }) ?? (() => {}) |
| 1296 | 1301 | } |
| 1297 | 1302 | if (typeof globalThis === 'undefined') return () => {} |
| 1298 | 1303 | 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 }) | |
| 1301 | 1307 | } |
| 1302 | 1308 | globalThis.addEventListener('craftDeepLink', listener) |
| 1303 | 1309 | return () => globalThis.removeEventListener('craftDeepLink', listener) |
| @@ -833,14 +833,16 @@ export interface CraftBridge { | ||
| 833 | 833 | /** |
| 834 | 834 | * Listen for deep links. |
| 835 | 835 | * |
| 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. | |
| 841 | 843 | * |
| 842 | 844 | * @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 | |
| 844 | 846 | */ |
| 845 | 847 | onLink(callback: (data: DeepLinkData) => void): (() => void) | void; |
| 846 | 848 | }; |
| @@ -1025,7 +1027,7 @@ export interface DeepLinkData { | ||
| 1025 | 1027 | query: string; |
| 1026 | 1028 | /** Parsed query parameters */ |
| 1027 | 1029 | queryParams?: Record<string, string>; |
| 1028 | /** True for the link that launched the app (iOS) */ | |
| 1030 | /** True for the link that launched the app */ | |
| 1029 | 1031 | initial?: boolean; |
| 1030 | 1032 | } |
| 1031 | 1033 | |
| @@ -379,7 +379,7 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 379 | 379 | .{ .name = "nativeResetDeepLinks", .signature = "()Z", .fnPtr = @ptrCast(&nativeResetDeepLinks) }, |
| 380 | 380 | .{ .name = "nativeSetInitialURL", .signature = "(Ljava/lang/String;)Z", .fnPtr = @ptrCast(&nativeSetInitialURL) }, |
| 381 | 381 | .{ .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) }, | |
| 383 | 383 | .{ .name = "nativeScreenshotReady", .signature = "([B)V", .fnPtr = @ptrCast(&nativeScreenshotReady) }, |
| 384 | 384 | .{ .name = "nativeScreenshotError", .signature = "(Ljava/lang/String;)V", .fnPtr = @ptrCast(&nativeScreenshotError) }, |
| 385 | 385 | .{ .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 | ||
| 1187 | 1187 | defer arena.deinit(); |
| 1188 | 1188 | const allocator = arena.allocator(); |
| 1189 | 1189 | 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"; | |
| 1191 | 1191 | events.settle(allocator, deeplink.resolve_global, result) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE); |
| 1192 | 1192 | return jni.JNI_TRUE; |
| 1193 | 1193 | } |
| 1194 | 1194 | |
| 1195 | fn nativeDispatchDeepLink(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring) callconv(.c) jni.jboolean { | |
| 1195 | fn nativeDispatchDeepLink(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring, initial: jni.jboolean) callconv(.c) jni.jboolean { | |
| 1196 | 1196 | const j = Jni.init(env); |
| 1197 | 1197 | var arena = std.heap.ArenaAllocator.init(backing); |
| 1198 | 1198 | defer arena.deinit(); |
| 1199 | 1199 | const allocator = arena.allocator(); |
| 1200 | 1200 | 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); | |
| 1203 | 1202 | events.emitEvent(allocator, deeplink.event_name, detail) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE); |
| 1204 | 1203 | return jni.JNI_TRUE; |
| 1205 | 1204 | } |
| @@ -3143,6 +3142,17 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 3143 | 3142 | "(Landroid/app/Activity;)Z", |
| 3144 | 3143 | std.mem.span(restore.signature), |
| 3145 | 3144 | ); |
| 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 | ); | |
| 3146 | 3156 | } |
| 3147 | 3157 | |
| 3148 | 3158 | test "the JavaVM table puts GetEnv where the header does" { |
| @@ -49,18 +49,6 @@ pub fn set(url: ?[]const u8) !void { | ||
| 49 | 49 | if (old) |bytes| std.heap.page_allocator.free(bytes); |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | pub 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 | ||
| 64 | 52 | pub fn snapshot(allocator: std.mem.Allocator) !?[]u8 { |
| 65 | 53 | lock(); |
| 66 | 54 | defer unlock(); |
| @@ -81,7 +69,12 @@ fn stringOrEmpty(j: Jni, value: jobject) !jobject { | ||
| 81 | 69 | } |
| 82 | 70 | |
| 83 | 71 | /// Build the exact object both Kotlin paths built for one URL. |
| 84 | pub 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. | |
| 77 | pub fn payload(j: Jni, allocator: std.mem.Allocator, url: []const u8, initial: ?bool) ![]u8 { | |
| 85 | 78 | try j.pushLocalFrame(32); |
| 86 | 79 | defer _ = j.popLocalFrame(null); |
| 87 | 80 | |
| @@ -137,17 +130,27 @@ pub fn payload(j: Jni, allocator: std.mem.Allocator, url: []const u8) ![]u8 { | ||
| 137 | 130 | } |
| 138 | 131 | try jsonPut(j, root, "queryParams", query_params); |
| 139 | 132 | |
| 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 | ||
| 140 | 142 | const rendered = try j.callObjectMethod(root, try j.methodId(json_cls, "toString", "()Ljava/lang/String;")); |
| 141 | 143 | return j.stringToUtf8(allocator, rendered); |
| 142 | 144 | } |
| 143 | 145 | |
| 144 | 146 | const testing = std.testing; |
| 145 | 147 | |
| 146 | test "reset, set and first-only storage preserve the bridge lifetime" { | |
| 148 | test "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). | |
| 147 | 151 | reset(); |
| 148 | 152 | try testing.expect(try snapshot(testing.allocator) == null); |
| 149 | try rememberFirst("craft://first"); | |
| 150 | try rememberFirst("craft://second"); | |
| 153 | try set("craft://first"); | |
| 151 | 154 | const first = (try snapshot(testing.allocator)).?; |
| 152 | 155 | defer testing.allocator.free(first); |
| 153 | 156 | try testing.expectEqualStrings("craft://first", first); |
| @@ -2,7 +2,8 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { init } from '../../packages/android/src/index' |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol' | |
| 5 | import type { DeepLinkResult } from './protocol' | |
| 6 | import { androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems } from './protocol' | |
| 6 | 7 | import { command, driverPage, waitForFile } from './support' |
| 7 | 8 | |
| 8 | 9 | /** |
| @@ -19,10 +20,17 @@ import { command, driverPage, waitForFile } from './support' | ||
| 19 | 20 | * Geolocation on, because that is what puts the location permissions in the |
| 20 | 21 | * manifest, and `pm grant` refuses a permission the manifest does not declare. |
| 21 | 22 | * 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. | |
| 22 | 26 | */ |
| 27 | const DEEP_LINK_SCHEME = 'crafte2eprobe' | |
| 28 | ||
| 23 | 29 | const CONFIG = { |
| 24 | 30 | enablePushNotifications: false, |
| 25 | 31 | enableGeolocation: true, |
| 32 | enableDeepLinks: true, | |
| 33 | urlSchemes: [DEEP_LINK_SCHEME], | |
| 26 | 34 | } |
| 27 | 35 | |
| 28 | 36 | /** |
| @@ -74,6 +82,11 @@ function legs(runtimeDir: string | null): Leg[] { | ||
| 74 | 82 | /** What JNI_OnLoad logs once it has bound the natives. */ |
| 75 | 83 | const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/ |
| 76 | 84 | |
| 85 | /** Whether the page gave up before it could report anything. */ | |
| 86 | function parseFatal(text: string): boolean { | |
| 87 | return hasTerminated(text) && /"event":"fatal"/.test(text) | |
| 88 | } | |
| 89 | ||
| 77 | 90 | async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) { |
| 78 | 91 | return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure }) |
| 79 | 92 | } |
| @@ -116,6 +129,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 116 | 129 | copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html')) |
| 117 | 130 | copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json')) |
| 118 | 131 | |
| 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 | ||
| 119 | 140 | // The generator writes gradle-wrapper.properties but never gradlew, so there |
| 120 | 141 | // is no wrapper to invoke. Call Gradle directly, the way the templates gate |
| 121 | 142 | // 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> { | ||
| 173 | 194 | if (runtimePermissionGranted(packageState, 'android.permission.ACCESS_FINE_LOCATION') === true) |
| 174 | 195 | failures.push('ACCESS_FINE_LOCATION is granted too, so the location case cannot tell approximate-only from precise') |
| 175 | 196 | |
| 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 | } | |
| 200 | 236 | |
| 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 | } | |
| 210 | 261 | |
| 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 | |
| 215 | 267 | |
| 216 | const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '' | |
| 268 | const logText = main.log | |
| 217 | 269 | if (!finished) |
| 218 | 270 | failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`) |
| 219 | 271 | |
| @@ -249,6 +301,55 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 249 | 301 | failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`) |
| 250 | 302 | } |
| 251 | 303 | |
| 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 | ||
| 252 | 353 | // Did the Zig runtime load, bind — and then actually answer? JNI_OnLoad |
| 253 | 354 | // reports the first two on the happy path as well as the failure ones, and |
| 254 | 355 | // 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> { | ||
| 265 | 366 | // the part worth quoting back. |
| 266 | 367 | const registered = logText.match(REGISTERED) |
| 267 | 368 | 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)) | |
| 269 | 372 | const zigActions: string[] = [] |
| 270 | 373 | |
| 271 | 374 | if (leg.requireZig) { |
| @@ -375,9 +375,11 @@ | ||
| 375 | 375 | // |
| 376 | 376 | // Runs after the suite, so the subscription is seconds after the bridge |
| 377 | 377 | // 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. | |
| 381 | 383 | // |
| 382 | 384 | // The link says which way to receive it. `receive=subscribe` only |
| 383 | 385 | // subscribes, and must get the launch link exactly once. `receive=both` |
| @@ -387,16 +389,30 @@ | ||
| 387 | 389 | function probeDeepLinks() { |
| 388 | 390 | var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null |
| 389 | 391 | 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' | |
| 391 | 394 | 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 | ) | |
| 393 | 399 | } |
| 394 | window.craft.deepLinks.onLink(function (detail) { | |
| 400 | var unsubscribe = window.craft.deepLinks.onLink(function (detail) { | |
| 395 | 401 | seen.onLink.push({ url: detail && detail.url, initial: detail && detail.initial }) |
| 396 | 402 | }) |
| 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) | |
| 400 | 416 | } |
| 401 | 417 | |
| 402 | 418 | function waitForBridge(attempt) { |
| @@ -428,7 +444,7 @@ | ||
| 428 | 444 | if (index >= selected.length) { |
| 429 | 445 | emit({ event: 'done', passed: passed, failed: failed }) |
| 430 | 446 | document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED' |
| 431 | if (platform === 'ios') probeDeepLinks() | |
| 447 | probeDeepLinks() | |
| 432 | 448 | return |
| 433 | 449 | } |
| 434 | 450 | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { 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' | |
| 4 | import { 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' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -373,6 +373,46 @@ describe('cold-start deep links', () => { | ||
| 373 | 373 | 'the both cold start never reported; see xcodebuild-deeplink.log', |
| 374 | 374 | ]) |
| 375 | 375 | }) |
| 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 | ||
| 385 | describe('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 | }) | |
| 376 | 416 | }) |
| 377 | 417 | |
| 378 | 418 | /** A minimal ELF64 little-endian file whose section header table names `sections`. */ |
| @@ -325,13 +325,45 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun? | ||
| 325 | 325 | return { ok: failures.length === 0, failures, planned, passed, failed } |
| 326 | 326 | } |
| 327 | 327 | |
| 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. */ | |
| 329 | 329 | export interface DeepLinkResult { |
| 330 | 330 | receive: 'subscribe' | 'both' |
| 331 | 331 | link: string |
| 332 | 332 | launch: string | null |
| 333 | 333 | onLink: { url?: string, initial?: boolean }[] |
| 334 | 334 | 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. */ | |
| 340 | export 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 | */ | |
| 351 | export 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 | |
| 335 | 367 | } |
| 336 | 368 | |
| 337 | 369 | /** |
| @@ -364,15 +396,21 @@ export function deepLinkResults(output: string): DeepLinkResult[] { | ||
| 364 | 396 | * `both` is the guard on the fix: a page that also calls getInitialURL must |
| 365 | 397 | * get the link from there and not a second time from onLink. |
| 366 | 398 | */ |
| 367 | export function deepLinkProblems(results: DeepLinkResult[], link: string): string[] { | |
| 399 | export function deepLinkProblems( | |
| 400 | results: DeepLinkResult[], | |
| 401 | link: string, | |
| 402 | evidence: (receive: DeepLinkResult['receive']) => string = () => 'xcodebuild-deeplink.log', | |
| 403 | ): string[] { | |
| 368 | 404 | const problems: string[] = [] |
| 369 | 405 | for (const receive of ['subscribe', 'both'] as const) { |
| 370 | 406 | const result = results.find(entry => entry.receive === receive) |
| 371 | 407 | const expectedLink = `${link}&receive=${receive}` |
| 372 | 408 | 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)}`) | |
| 374 | 410 | continue |
| 375 | 411 | } |
| 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`) | |
| 376 | 414 | if (result.launch !== expectedLink) { |
| 377 | 415 | problems.push(`the ${receive} cold start was not launched by its link: the page saw ${JSON.stringify(result.launch)}`) |
| 378 | 416 | continue |