ReviewOS

also looking at this

craft-native/craft

fix(android): answer the page from haptic, vibrate and the speech calls

#234
Merged glennmichael123 wants to merge fix/android-feedback-promises into main
7 files +364 -114
packages/android/src/page-script.test.tsmodified+132-12
Changes to packages/android/src/page-script.test.ts
@@ -19,8 +19,12 @@ function pageScript(enabled = true): string {
1919 .slice(start + opening.length, end)
2020 .replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(' '))
2121 .replace(/\{\{ENABLE_[A-Z_]+\}\}/g, String(enabled))
22 // Kotlin string templates, `${isBiometricAvailable()}` and the like. Each
23 // is a Boolean in the capabilities object.
22 // Whether the device itself can do speech, which `capabilities` ands with
23 // the build flag. It follows `enabled` so that turning speech on in a
24 // test describes a device that can serve it.
25 .replace('${SpeechRecognizer.isRecognitionAvailable(activity)}', String(enabled))
26 // The rest of the Kotlin string templates, `${isBiometricAvailable()}` and
27 // the like. Each is a Boolean in the capabilities object.
2428 .replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, 'false')
2529 if (/\{\{|\$/.test(script)) throw new Error('the page script still holds a template placeholder')
2630 return script
@@ -28,9 +32,18 @@ function pageScript(enabled = true): string {
2832
2933type Listener = (event: { type: string, detail: unknown }) => void
3034
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, enabled = true) {
35/**
36 * `beforeInject` runs first, the way a page's own script runs before Android
37 * injects the bridge. `enabled` sets every `` flag, and `answers`
38 * says what a named CraftAndroid method returns, the way Kotlin answers it.
39 */
40function loadPage(
41 beforeInject?: (page: Record<string, any>) => void,
42 enabled = true,
43 answers: Record<string, unknown> = {},
44) {
3345 const calls: string[] = []
46 const args: unknown[][] = []
3447 const listeners: Record<string, Listener[]> = {}
3548 const page: Record<string, any> = {
3649 addEventListener: (type: string, listener: Listener) => {
@@ -54,11 +67,16 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, enabled =
5467 this.detail = options?.detail
5568 }
5669 }
57 // Every method the page calls answers undefined and is recorded by name.
70 // Every method the page calls is recorded by name, and answers whatever
71 // `answers` says Kotlin would. Undefined by default, which is also what a
72 // WebView reports for a @JavascriptInterface method that threw.
5873 const CraftAndroid = new Proxy({}, {
59 get: (_, name: string) => (..._args: unknown[]) => {
74 get: (_, name: string) => (...called: unknown[]) => {
6075 calls.push(name)
61 return undefined
76 args.push(called)
77 const answer = answers[name]
78 if (answer instanceof Error) throw answer
79 return answer
6280 },
6381 })
6482 const quiet = { log() {}, warn() {}, error() {}, info() {}, debug() {} }
@@ -80,6 +98,9 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, enabled =
8098 return {
8199 page,
82100 calls,
101 args,
102 /** The arguments of the last call to `name`. */
103 argsOf: (name: string) => args[calls.lastIndexOf(name)],
83104 inject,
84105 get craft() { return page.craft },
85106 // What CraftBridge.dispatchDeepLink evaluates, once the script has run.
@@ -214,12 +235,13 @@ describe('the injected Android page script', () => {
214235 }, 5000)
215236
216237 it('does nothing, rather than refusing, for a call with no answer to give', async () => {
217 // haptic and vibrate still return undefined (#219), so there is no
218 // promise to reject; native must not be asked to buzz either.
238 // What is left answering nothing after #219: flashlight, keepAwake and
239 // the flat watch API (#220). There is no promise to reject, so the call
240 // no-ops, and native must not be asked to do it either.
219241 const page = loadPage(undefined, false)
220242
221 expect(page.craft.haptic('light')).toBeUndefined()
222 expect(page.craft.vibrate([100])).toBeUndefined()
243 expect(page.craft.setKeepAwake(true)).toBeUndefined()
244 expect(page.craft.toggleFlashlight()).toBeUndefined()
223245 expect(page.calls).toEqual([])
224246 })
225247
@@ -229,11 +251,109 @@ describe('the injected Android page script', () => {
229251 expect(page.craft.capabilities.share).toBe(true)
230252 void page.craft.share('hello')
231253 void page.craft.secureStore.set('k', 'v')
232 page.craft.haptic('light')
254 // A tick, because haptic reaches native from inside a promise, so that a
255 // native failure rejects rather than throwing at the caller.
256 void page.craft.haptic('light')
257 await tick()
233258
234259 expect(page.calls).toEqual(['share', 'secureSet', 'haptic'])
235260 })
236261
262 // #219: all four used to return undefined, so a page that awaited one got
263 // `undefined`, which reads as success, and a page written for iOS behaved
264 // differently here. @JavascriptInterface answers synchronously, so the
265 // answer is the Kotlin method's own return value.
266 const answering: [string, string, (craft: any) => unknown][] = [
267 ['haptic', 'haptic', craft => craft.haptic('light')],
268 ['vibrate', 'vibrate', craft => craft.vibrate([100, 50, 100])],
269 ['startListening', 'startListening', craft => craft.startListening()],
270 ['stopListening', 'stopListening', craft => craft.stopListening()],
271 ]
272
273 for (const [name, method, call] of answering) {
274 it(`hands ${name} the answer Kotlin returned`, async () => {
275 const page = loadPage(undefined, true, { [method]: true })
276 const returned = call(page.craft)
277
278 expect(returned).toBeInstanceOf(Promise)
279 expect(await returned).toBe(true)
280 expect(page.calls).toContain(method)
281 })
282
283 it(`answers ${name} false when the device did not take it`, async () => {
284 // Kotlin returns false, and a Kotlin method that throws reaches the
285 // page as undefined. Neither is `true`, and neither may be a hang.
286 const page = loadPage(undefined, true, { [method]: undefined })
287 expect(await call(page.craft)).toBe(false)
288 })
289 }
290
291 it('rejects, rather than throwing at the caller, when native fails', async () => {
292 // An Error in `answers` is thrown, the way a @JavascriptInterface method
293 // that throws reaches the page. The caller gets a rejected promise, not
294 // an exception at the call site, which is why the call sits in `.then`.
295 const page = loadPage(undefined, true, { haptic: new Error('Vibrator died') })
296
297 let returned: Promise<unknown> | undefined
298 expect(() => { returned = page.craft.haptic('light') }).not.toThrow()
299 await expect(returned).rejects.toThrow('Vibrator died')
300 })
301
302 it('lets a real native failure through haptics.impact, unlike a refusal', async () => {
303 const page = loadPage(undefined, true, { haptic: new Error('Vibrator died') })
304 await expect(page.craft.haptics.impact('heavy')).rejects.toThrow('Vibrator died')
305 })
306
307 it('sends vibrate the pattern as JSON, the shape Kotlin parses', async () => {
308 const page = loadPage(undefined, true, { vibrate: true })
309 await page.craft.vibrate([100, 50])
310
311 expect(page.argsOf('vibrate')).toEqual(['[100,50]'])
312 })
313
314 it('refuses the three that a capability gates, now that they can carry it', async () => {
315 // Before #219 these warned and returned undefined, because there was no
316 // promise to reject. #209 left that note in the gate; this collects it.
317 const page = loadPage(undefined, false)
318
319 await expect(page.craft.haptic('light')).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' })
320 await expect(page.craft.vibrate([100])).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' })
321 await expect(page.craft.startListening()).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' })
322 expect(page.calls).toEqual([])
323 }, 5000)
324
325 it('leaves stopListening ungated, as iOS does', async () => {
326 // Tearing a screen down must not depend on which build it runs on.
327 const page = loadPage(undefined, false, { stopListening: true })
328
329 expect(await page.craft.stopListening()).toBe(true)
330 expect(page.calls).toEqual(['stopListening'])
331 }, 5000)
332
333 // The other half of #207's split, which Android could not have until
334 // craft.haptic() had an answer: the raw call reports the refusal, and the
335 // feedback helpers treat a capability left off as nothing to play.
336 const feedback: [string, (craft: any) => Promise<unknown>][] = [
337 ['impact', craft => craft.haptics.impact('heavy')],
338 ['notification', craft => craft.haptics.notification('error')],
339 ['selection', craft => craft.haptics.selection()],
340 ['vibrate', craft => craft.haptics.vibrate([100])],
341 ]
342
343 for (const [name, call] of feedback) {
344 it(`settles haptics.${name} with nothing played when haptics are off`, async () => {
345 const page = loadPage(undefined, false)
346 expect(await call(page.craft)).toBeUndefined()
347 expect(page.calls).toEqual([])
348 }, 5000)
349
350 it(`plays haptics.${name} when the app was built with haptics`, async () => {
351 const page = loadPage(undefined, true, { haptic: true, vibrate: true })
352 expect(await call(page.craft)).toBeUndefined()
353 expect(page.calls.length).toBe(1)
354 })
355 }
356
237357 it('does not hand a link to a second subscriber after the script runs again in the same page', async () => {
238358 const page = loadPage()
239359 const first: unknown[] = []