also looking at this
fix(android): hand the launch link to the page that subscribes for it
#232
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.
| @@ -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 | |
| @@ -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); |