ReviewOS

also looking at this

craft-native/craft

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

#232
Merged glennmichael123 wants to merge fix/android-deep-link-replay into main
17 files +776 -136
packages/android/src/page-script.test.tsadded+217-0
Changes to packages/android/src/page-script.test.ts
@@ -0,0 +1,217 @@
1import { readFileSync } from 'node:fs'
2import { join } from 'node:path'
3import { describe, expect, it } from 'bun:test'
4import { renderAndroidPromiseRuntime } from './promise-runtime'
5
6// The script CraftBridge injects into every page, run here against a fake
7// CraftAndroid. Nothing else runs it off a device, and a syntax error in it
8// compiles fine in Kotlin and leaves window.craft undefined on the emulator.
9
10const template = readFileSync(join(import.meta.dir, '../templates/CraftBridge.kt.template'), 'utf8')
11
12/** The page script, as Kotlin evaluates it, with every flag off. */
13function pageScript(): string {
14 const opening = ' val script = """\n'
15 const start = template.indexOf(opening)
16 if (start === -1) throw new Error('CraftBridge.kt.template no longer builds `val script = """`')
17 const end = template.indexOf('\n """.trimIndent()', start)
18 const script = template
19 .slice(start + opening.length, end)
20 .replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(' '))
21 .replace(/\{\{ENABLE_[A-Z_]+\}\}/g, 'false')
22 // Kotlin string templates, `${isBiometricAvailable()}` and the like. Each
23 // is a Boolean in the capabilities object.
24 .replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, 'false')
25 if (/\{\{|\$/.test(script)) throw new Error('the page script still holds a template placeholder')
26 return script
27}
28
29type Listener = (event: { type: string, detail: unknown }) => void
30
31/** `beforeInject` runs first, the way a page's own script runs before Android injects the bridge. */
32function loadPage(beforeInject?: (page: Record<string, any>) => void) {
33 const calls: string[] = []
34 const listeners: Record<string, Listener[]> = {}
35 const page: Record<string, any> = {
36 addEventListener: (type: string, listener: Listener) => {
37 (listeners[type] ||= []).push(listener)
38 },
39 removeEventListener: (type: string, listener: Listener) => {
40 listeners[type] = (listeners[type] || []).filter(existing => existing !== listener)
41 },
42 dispatchEvent: (event: { type: string, detail: unknown }) => {
43 for (const listener of [...(listeners[event.type] || [])]) listener(event)
44 return true
45 },
46 location: { href: 'https://appassets.androidplatform.net/index.html' },
47 }
48 page.window = page
49 class CustomEvent {
50 type: string
51 detail: unknown
52 constructor(type: string, options?: { detail?: unknown }) {
53 this.type = type
54 this.detail = options?.detail
55 }
56 }
57 // Every method the page calls answers undefined and is recorded by name.
58 const CraftAndroid = new Proxy({}, {
59 get: (_, name: string) => (..._args: unknown[]) => {
60 calls.push(name)
61 return undefined
62 },
63 })
64 const quiet = { log() {}, warn() {}, error() {}, info() {}, debug() {} }
65 const document = { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', readyState: 'complete' }
66
67 const inject = () => {
68 // eslint-disable-next-line no-new-func
69 new Function('window', 'document', 'CraftAndroid', 'CustomEvent', 'console', pageScript())(
70 page,
71 document,
72 CraftAndroid,
73 CustomEvent,
74 quiet,
75 )
76 }
77 beforeInject?.(page)
78 inject()
79
80 return {
81 page,
82 calls,
83 inject,
84 get craft() { return page.craft },
85 // What CraftBridge.dispatchDeepLink evaluates, once the script has run.
86 link: (url: string, initial: boolean) =>
87 page.dispatchEvent(new CustomEvent('craftDeepLink', { detail: { url, initial } })),
88 }
89}
90
91const tick = () => new Promise(resolve => setTimeout(resolve, 5))
92
93describe('the injected Android page script', () => {
94 // #215: every one of these received nothing before, because a link was
95 // dispatched once, to whoever had subscribed by then, which was nobody.
96 it('hands the launch link to a page that subscribes after it arrived', async () => {
97 const page = loadPage()
98 page.link('app://launch', true)
99
100 const seen: unknown[] = []
101 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
102 await tick()
103
104 expect(seen).toEqual([{ url: 'app://launch', initial: true }])
105 })
106
107 it('returns an unsubscribe from onLink and onDeepLink', () => {
108 const page = loadPage()
109 expect(typeof page.craft.deepLinks.onLink(() => {})).toBe('function')
110 expect(typeof page.craft.onDeepLink(() => {})).toBe('function')
111 })
112
113 it('leaves the launch link to getInitialURL when the page asks for it first', async () => {
114 const page = loadPage()
115 page.link('app://launch', true)
116
117 const initial = page.craft.deepLinks.getInitialURL()
118 const seen: unknown[] = []
119 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
120 await tick()
121
122 expect(seen).toEqual([])
123 expect(page.calls).toContain('getInitialURL')
124 page.page._craftDeepLinkResolve({ url: 'app://launch' })
125 expect(await initial).toEqual({ url: 'app://launch' })
126 })
127
128 it('leaves the launch link to getInitialURL when the page asks for it second', async () => {
129 const page = loadPage()
130 page.link('app://launch', true)
131
132 const seen: unknown[] = []
133 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
134 void page.craft.deepLinks.getInitialURL()
135 await tick()
136
137 expect(seen).toEqual([])
138 })
139
140 it('delivers the launch link once to a craftReady handler that asks for it both ways', async () => {
141 // The handler runs inside the script, before native has dispatched the
142 // link at all, so there is nothing held yet for getInitialURL to claim.
143 const seen: unknown[] = []
144 let subscribed = false
145 const page = loadPage((window) => {
146 window.addEventListener('craftReady', () => {
147 void window.craft.deepLinks.getInitialURL()
148 subscribed = typeof window.craft.deepLinks.onLink((detail: unknown) => seen.push(detail)) === 'function'
149 })
150 })
151 page.link('app://launch', true)
152 await tick()
153
154 expect(subscribed).toBe(true)
155 expect(page.calls).toContain('getInitialURL')
156 expect(seen).toEqual([])
157
158 // And the subscription is live: only the launch link was held back.
159 page.link('app://warm', false)
160 expect(seen).toEqual([{ url: 'app://warm', initial: false }])
161 })
162
163 it('shares one replay between onDeepLink and deepLinks.onLink', async () => {
164 const page = loadPage()
165 page.link('app://launch', true)
166
167 const legacy: unknown[] = []
168 const current: unknown[] = []
169 page.craft.onDeepLink((detail: unknown) => legacy.push(detail))
170 page.craft.deepLinks.onLink((detail: unknown) => current.push(detail))
171 await tick()
172
173 expect(legacy).toEqual([{ url: 'app://launch', initial: true }])
174 expect(current).toEqual([])
175 })
176
177 it('drops held links for a subscriber that unsubscribed in the same tick', async () => {
178 const page = loadPage()
179 page.link('app://launch', true)
180
181 const seen: unknown[] = []
182 const unsubscribe = page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
183 unsubscribe()
184 await tick()
185 page.link('app://warm', false)
186
187 expect(seen).toEqual([])
188 })
189
190 it('delivers a link that arrives after subscribing once, as it arrives', async () => {
191 const page = loadPage()
192 const seen: unknown[] = []
193 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
194 await tick()
195 page.link('app://warm', false)
196
197 expect(seen).toEqual([{ url: 'app://warm', initial: false }])
198 })
199
200 it('does not hand a link to a second subscriber after the script runs again in the same page', async () => {
201 const page = loadPage()
202 const first: unknown[] = []
203 page.craft.deepLinks.onLink((detail: unknown) => first.push(detail))
204 await tick()
205
206 page.inject()
207 page.link('app://warm', false)
208 const second: unknown[] = []
209 page.craft.deepLinks.onLink((detail: unknown) => second.push(detail))
210 await tick()
211
212 // The first subscriber heard it live. A second copy of the script that
213 // buffered it again would replay it to the next subscriber as well.
214 expect(first).toEqual([{ url: 'app://warm', initial: false }])
215 expect(second).toEqual([])
216 })
217})