also looking at this
fix(android): answer the page from haptic, vibrate and the speech calls
#234
7 files
+364
-114
| @@ -19,8 +19,12 @@ function pageScript(enabled = true): string { | ||
| 19 | 19 | .slice(start + opening.length, end) |
| 20 | 20 | .replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(' ')) |
| 21 | 21 | .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. | |
| 24 | 28 | .replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, 'false') |
| 25 | 29 | if (/\{\{|\$/.test(script)) throw new Error('the page script still holds a template placeholder') |
| 26 | 30 | return script |
| @@ -28,9 +32,18 @@ function pageScript(enabled = true): string { | ||
| 28 | 32 | |
| 29 | 33 | type Listener = (event: { type: string, detail: unknown }) => void |
| 30 | 34 | |
| 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, 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 | */ | |
| 40 | function loadPage( | |
| 41 | beforeInject?: (page: Record<string, any>) => void, | |
| 42 | enabled = true, | |
| 43 | answers: Record<string, unknown> = {}, | |
| 44 | ) { | |
| 33 | 45 | const calls: string[] = [] |
| 46 | const args: unknown[][] = [] | |
| 34 | 47 | const listeners: Record<string, Listener[]> = {} |
| 35 | 48 | const page: Record<string, any> = { |
| 36 | 49 | addEventListener: (type: string, listener: Listener) => { |
| @@ -54,11 +67,16 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, enabled = | ||
| 54 | 67 | this.detail = options?.detail |
| 55 | 68 | } |
| 56 | 69 | } |
| 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. | |
| 58 | 73 | const CraftAndroid = new Proxy({}, { |
| 59 | get: (_, name: string) => (..._args: unknown[]) => { | |
| 74 | get: (_, name: string) => (...called: unknown[]) => { | |
| 60 | 75 | 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 | |
| 62 | 80 | }, |
| 63 | 81 | }) |
| 64 | 82 | const quiet = { log() {}, warn() {}, error() {}, info() {}, debug() {} } |
| @@ -80,6 +98,9 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, enabled = | ||
| 80 | 98 | return { |
| 81 | 99 | page, |
| 82 | 100 | calls, |
| 101 | args, | |
| 102 | /** The arguments of the last call to `name`. */ | |
| 103 | argsOf: (name: string) => args[calls.lastIndexOf(name)], | |
| 83 | 104 | inject, |
| 84 | 105 | get craft() { return page.craft }, |
| 85 | 106 | // What CraftBridge.dispatchDeepLink evaluates, once the script has run. |
| @@ -214,12 +235,13 @@ describe('the injected Android page script', () => { | ||
| 214 | 235 | }, 5000) |
| 215 | 236 | |
| 216 | 237 | 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. | |
| 219 | 241 | const page = loadPage(undefined, false) |
| 220 | 242 | |
| 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() | |
| 223 | 245 | expect(page.calls).toEqual([]) |
| 224 | 246 | }) |
| 225 | 247 | |
| @@ -229,11 +251,109 @@ describe('the injected Android page script', () => { | ||
| 229 | 251 | expect(page.craft.capabilities.share).toBe(true) |
| 230 | 252 | void page.craft.share('hello') |
| 231 | 253 | 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() | |
| 233 | 258 | |
| 234 | 259 | expect(page.calls).toEqual(['share', 'secureSet', 'haptic']) |
| 235 | 260 | }) |
| 236 | 261 | |
| 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 | ||
| 237 | 357 | it('does not hand a link to a second subscriber after the script runs again in the same page', async () => { |
| 238 | 358 | const page = loadPage() |
| 239 | 359 | const first: unknown[] = [] |