also looking at this
fix(android): answer the page from haptic, vibrate and the speech calls
#234
7 files
+364
-114
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.
| @@ -377,6 +377,42 @@ describe('Craft Android builder', () => { | ||
| 377 | 377 | expect(bridge).toContain('if (!keepAwakeEnabled && disabled("keepAwake", "setKeepAwake")) return false') |
| 378 | 378 | }) |
| 379 | 379 | |
| 380 | it('answers the page from haptic, vibrate and the speech calls', async () => { | |
| 381 | // #219: all four returned Unit, so the page got undefined where iOS | |
| 382 | // returns a promise, and a grant of RECORD_AUDIO went nowhere. | |
| 383 | const output = mkdtempSync(join(tmpdir(), 'craft-android-feedback-answers-')) | |
| 384 | await init({ | |
| 385 | name: 'WildLoop', | |
| 386 | packageName: 'org.wildloop.app', | |
| 387 | output, | |
| 388 | config: { enableHaptics: true, enableSpeechRecognition: true }, | |
| 389 | }) | |
| 390 | ||
| 391 | const bridge = readFileSync(join(output, 'app/src/main/java/org/wildloop/app/CraftBridge.kt'), 'utf8') | |
| 392 | for (const signature of [ | |
| 393 | 'fun haptic(style: String): Boolean', | |
| 394 | 'fun vibrate(patternJson: String): Boolean', | |
| 395 | 'fun startListening(): Boolean', | |
| 396 | 'fun stopListening(): Boolean', | |
| 397 | ]) { | |
| 398 | expect(bridge).toContain(signature) | |
| 399 | } | |
| 400 | ||
| 401 | // The page reads that return value rather than dropping it. | |
| 402 | expect(bridge).toContain("return CraftAndroid.haptic(style || 'medium') === true;") | |
| 403 | expect(bridge).toContain('return CraftAndroid.startListening() === true;') | |
| 404 | ||
| 405 | // The microphone grant starts the recogniser, the way iOS starts from | |
| 406 | // inside its authorization callback, and a denial is not silence. | |
| 407 | expect(bridge).toContain('private fun beginSpeechRecognition()') | |
| 408 | expect(bridge).toContain('if (requestCode == REQUEST_SPEECH) {') | |
| 409 | expect(bridge).toContain('if (isPermissionGranted(Manifest.permission.RECORD_AUDIO)) beginSpeechRecognition()') | |
| 410 | expect(bridge).toContain('"Microphone permission denied"') | |
| 411 | ||
| 412 | // And stopping says the session ended, once, as iOS does. | |
| 413 | expect(bridge).toContain('sendEvent("craftSpeechEnd", emptyMap())') | |
| 414 | }) | |
| 415 | ||
| 380 | 416 | it('routes external Activity results back to every pending media promise', async () => { |
| 381 | 417 | const output = mkdtempSync(join(tmpdir(), 'craft-android-activity-results-')) |
| 382 | 418 | await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output }) |
| @@ -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[] = [] |
| @@ -381,17 +381,29 @@ class CraftBridge( | ||
| 381 | 381 | screenCapture: false |
| 382 | 382 | }, |
| 383 | 383 | |
| 384 | // Haptic feedback | |
| 384 | // Haptic feedback. | |
| 385 | // | |
| 386 | // These four answer the page, as iOS does since #207. A | |
| 387 | // @JavascriptInterface method answers its caller | |
| 388 | // synchronously, so unlike the calls above there is no | |
| 389 | // channel to park: the Kotlin method's own return value is | |
| 390 | // the answer. The call goes inside `.then` so that a native | |
| 391 | // failure rejects rather than throwing at the caller, which | |
| 392 | // is the shape every other promise here has. | |
| 393 | // | |
| 394 | // `=== true` because a WebView reports some failures as | |
| 395 | // `undefined` rather than as a throw, and `undefined` is | |
| 396 | // exactly what #219 was about. | |
| 385 | 397 | haptic: function(style) { |
| 386 | CraftAndroid.haptic(style || 'medium'); | |
| 398 | return Promise.resolve().then(function() { return CraftAndroid.haptic(style || 'medium') === true; }); | |
| 387 | 399 | }, |
| 388 | 400 | |
| 389 | 401 | // Speech recognition |
| 390 | 402 | startListening: function() { |
| 391 | CraftAndroid.startListening(); | |
| 403 | return Promise.resolve().then(function() { return CraftAndroid.startListening() === true; }); | |
| 392 | 404 | }, |
| 393 | 405 | stopListening: function() { |
| 394 | CraftAndroid.stopListening(); | |
| 406 | return Promise.resolve().then(function() { return CraftAndroid.stopListening() === true; }); | |
| 395 | 407 | }, |
| 396 | 408 | |
| 397 | 409 | // Share. Resolves true when the person picks an app, false |
| @@ -526,7 +538,7 @@ class CraftBridge( | ||
| 526 | 538 | |
| 527 | 539 | // Vibration Pattern |
| 528 | 540 | vibrate: function(pattern) { |
| 529 | CraftAndroid.vibrate(JSON.stringify(pattern)); | |
| 541 | return Promise.resolve().then(function() { return CraftAndroid.vibrate(JSON.stringify(pattern)) === true; }); | |
| 530 | 542 | }, |
| 531 | 543 | |
| 532 | 544 | // App State |
| @@ -1390,18 +1402,18 @@ class CraftBridge( | ||
| 1390 | 1402 | // |
| 1391 | 1403 | // The refusal is made here, from the same capabilities object the |
| 1392 | 1404 | // page can read, because that object is what this bridge promises. |
| 1393 | // Calls that answer nothing yet (#219) cannot carry a refusal, so | |
| 1394 | // they do nothing and say so on the console, which is what a | |
| 1395 | // capability the app never asked for should do. | |
| 1405 | // Calls that answer nothing cannot carry a refusal, so they do | |
| 1406 | // nothing and say so on the console, which is what a capability | |
| 1407 | // the app never asked for should do. What is left answering | |
| 1408 | // nothing is flashlight, keepAwake and the flat watch API (#220). | |
| 1396 | 1409 | // |
| 1397 | 1410 | // Kotlin refuses the ungated-by-Android ones a second time, for a |
| 1398 | 1411 | // caller that reaches CraftAndroid without going through here. |
| 1399 | 1412 | (function installCapabilityGates(craft) { |
| 1400 | 1413 | var gated = [ |
| 1401 | ['haptic', 'haptics'], | |
| 1402 | ['vibrate', 'haptics'], | |
| 1403 | ['startListening', 'speechRecognition'], | |
| 1404 | ['stopListening', 'speechRecognition'], | |
| 1414 | ['haptic', 'haptics', true], | |
| 1415 | ['vibrate', 'haptics', true], | |
| 1416 | ['startListening', 'speechRecognition', true], | |
| 1405 | 1417 | ['setFlashlight', 'flashlight'], |
| 1406 | 1418 | ['toggleFlashlight', 'flashlight'], |
| 1407 | 1419 | ['setKeepAwake', 'keepAwake'], |
| @@ -1512,11 +1524,26 @@ class CraftBridge( | ||
| 1512 | 1524 | getInfo: function() { return Promise.resolve().then(function() { return craft.getDeviceInfo(); }); }, |
| 1513 | 1525 | getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); } |
| 1514 | 1526 | }; |
| 1527 | // Haptics are feedback, so an app built without them has | |
| 1528 | // nothing to play here rather than something to fail: `await | |
| 1529 | // haptics.selection()` in the middle of a flow must not stop | |
| 1530 | // the flow. Any other failure still rejects, and | |
| 1531 | // craft.haptic() itself reports the refusal. The same split | |
| 1532 | // iOS draws since #207, which Android could not draw until | |
| 1533 | // craft.haptic() had an answer to give. | |
| 1534 | function hapticFeedback(answer) { | |
| 1535 | return answer.then(function() {}, function(error) { | |
| 1536 | if (error && error.code === 'CAPABILITY_DISABLED') return; | |
| 1537 | throw error; | |
| 1538 | }); | |
| 1539 | } | |
| 1515 | 1540 | craft.haptics = { |
| 1516 | impact: function(style) { return Promise.resolve().then(function() { craft.haptic(style || 'medium'); }); }, | |
| 1517 | notification: function(type) { return Promise.resolve().then(function() { craft.haptic(type === 'error' ? 'heavy' : 'light'); }); }, | |
| 1518 | selection: function() { return Promise.resolve().then(function() { craft.haptic('selection'); }); }, | |
| 1519 | vibrate: function(pattern) { return Promise.resolve().then(function() { craft.vibrate(pattern || []); }); } | |
| 1541 | impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); }, | |
| 1542 | notification: function(type) { | |
| 1543 | return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light')); | |
| 1544 | }, | |
| 1545 | selection: function() { return hapticFeedback(craft.haptic('selection')); }, | |
| 1546 | vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); } | |
| 1520 | 1547 | }; |
| 1521 | 1548 | craft.camera = { |
| 1522 | 1549 | takePicture: function() { return legacyOpenCamera().then(normalizePhoto); }, |
| @@ -1647,10 +1674,16 @@ class CraftBridge( | ||
| 1647 | 1674 | |
| 1648 | 1675 | // ==================== Haptics ==================== |
| 1649 | 1676 | |
| 1677 | /** | |
| 1678 | * Whether the device took the call, which is the answer the page awaits | |
| 1679 | * since #219. False is "nothing played": haptics are off, or the vibrator | |
| 1680 | * refused. It is never an error, because haptics decorate a flow rather | |
| 1681 | * than carry it. | |
| 1682 | */ | |
| 1650 | 1683 | @JavascriptInterface |
| 1651 | fun haptic(style: String) { | |
| 1652 | if (!hapticsEnabled && disabled("haptics", "haptic")) return | |
| 1653 | if (CraftNative.haptic(activity, style)) return | |
| 1684 | fun haptic(style: String): Boolean { | |
| 1685 | if (!hapticsEnabled && disabled("haptics", "haptic")) return false | |
| 1686 | if (CraftNative.haptic(activity, style)) return true | |
| 1654 | 1687 | |
| 1655 | 1688 | val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
| 1656 | 1689 | val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager |
| @@ -1676,98 +1709,126 @@ class CraftBridge( | ||
| 1676 | 1709 | @Suppress("DEPRECATION") |
| 1677 | 1710 | vibrator.vibrate(20) |
| 1678 | 1711 | } |
| 1712 | return true | |
| 1679 | 1713 | } |
| 1680 | 1714 | |
| 1681 | 1715 | // ==================== Speech Recognition ==================== |
| 1682 | 1716 | |
| 1717 | /** | |
| 1718 | * Whether the request was taken, which is what iOS answers too: the | |
| 1719 | * outcome arrives as craftSpeech* events, not here (#207, #219). | |
| 1720 | * | |
| 1721 | * Asking for the microphone counts as taken. iOS starts recording from | |
| 1722 | * inside its authorization callback, so Android does the same from | |
| 1723 | * onRequestPermissionsResult below; before #219 the grant was dropped and | |
| 1724 | * the page heard nothing at all, not even craftSpeechError. | |
| 1725 | */ | |
| 1683 | 1726 | @JavascriptInterface |
| 1684 | fun startListening() { | |
| 1685 | if (!speechEnabled && disabled("speechRecognition", "startListening")) return | |
| 1686 | if (CraftNative.startListening(activity)) return | |
| 1727 | fun startListening(): Boolean { | |
| 1728 | if (!speechEnabled && disabled("speechRecognition", "startListening")) return false | |
| 1729 | if (CraftNative.startListening(activity)) return true | |
| 1687 | 1730 | |
| 1688 | 1731 | if (!SpeechRecognizer.isRecognitionAvailable(activity)) { |
| 1689 | 1732 | sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available")) |
| 1690 | return | |
| 1733 | return false | |
| 1691 | 1734 | } |
| 1692 | 1735 | |
| 1693 | 1736 | activity.runOnUiThread { |
| 1694 | 1737 | if (closed) return@runOnUiThread |
| 1695 | 1738 | if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) |
| 1696 | 1739 | != PackageManager.PERMISSION_GRANTED) { |
| 1697 | requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), 100) | |
| 1740 | requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_SPEECH) | |
| 1698 | 1741 | return@runOnUiThread |
| 1699 | 1742 | } |
| 1743 | beginSpeechRecognition() | |
| 1744 | } | |
| 1745 | return true | |
| 1746 | } | |
| 1700 | 1747 | |
| 1701 | speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity) | |
| 1702 | speechRecognizer?.setRecognitionListener(object : RecognitionListener { | |
| 1703 | override fun onReadyForSpeech(params: Bundle?) { | |
| 1704 | isListening = true | |
| 1705 | sendEvent("craftSpeechStart", emptyMap()) | |
| 1706 | haptic("light") | |
| 1707 | } | |
| 1708 | ||
| 1709 | override fun onBeginningOfSpeech() {} | |
| 1710 | override fun onRmsChanged(rmsdB: Float) {} | |
| 1711 | override fun onBufferReceived(buffer: ByteArray?) {} | |
| 1712 | override fun onEndOfSpeech() {} | |
| 1713 | ||
| 1714 | override fun onError(error: Int) { | |
| 1715 | isListening = false | |
| 1716 | val errorMsg = when (error) { | |
| 1717 | SpeechRecognizer.ERROR_AUDIO -> "Audio recording error" | |
| 1718 | SpeechRecognizer.ERROR_CLIENT -> "Client error" | |
| 1719 | SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions" | |
| 1720 | SpeechRecognizer.ERROR_NETWORK -> "Network error" | |
| 1721 | SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" | |
| 1722 | SpeechRecognizer.ERROR_NO_MATCH -> "No match" | |
| 1723 | SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" | |
| 1724 | SpeechRecognizer.ERROR_SERVER -> "Server error" | |
| 1725 | SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout" | |
| 1726 | else -> "Unknown error" | |
| 1727 | } | |
| 1728 | sendEvent("craftSpeechError", mapOf("error" to errorMsg)) | |
| 1729 | haptic("light") | |
| 1730 | } | |
| 1731 | ||
| 1732 | override fun onResults(results: Bundle?) { | |
| 1733 | isListening = false | |
| 1734 | val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) | |
| 1735 | val transcript = matches?.firstOrNull() ?: "" | |
| 1736 | sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true)) | |
| 1737 | sendEvent("craftSpeechEnd", emptyMap()) | |
| 1738 | haptic("light") | |
| 1739 | } | |
| 1748 | /** The recogniser itself, on the main thread with RECORD_AUDIO granted. */ | |
| 1749 | private fun beginSpeechRecognition() { | |
| 1750 | if (closed) return | |
| 1751 | speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity) | |
| 1752 | speechRecognizer?.setRecognitionListener(object : RecognitionListener { | |
| 1753 | override fun onReadyForSpeech(params: Bundle?) { | |
| 1754 | isListening = true | |
| 1755 | sendEvent("craftSpeechStart", emptyMap()) | |
| 1756 | haptic("light") | |
| 1757 | } | |
| 1740 | 1758 | |
| 1741 | override fun onPartialResults(partialResults: Bundle?) { | |
| 1742 | val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) | |
| 1743 | val transcript = matches?.firstOrNull() ?: "" | |
| 1744 | sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to false)) | |
| 1759 | override fun onBeginningOfSpeech() {} | |
| 1760 | override fun onRmsChanged(rmsdB: Float) {} | |
| 1761 | override fun onBufferReceived(buffer: ByteArray?) {} | |
| 1762 | override fun onEndOfSpeech() {} | |
| 1763 | ||
| 1764 | override fun onError(error: Int) { | |
| 1765 | isListening = false | |
| 1766 | val errorMsg = when (error) { | |
| 1767 | SpeechRecognizer.ERROR_AUDIO -> "Audio recording error" | |
| 1768 | SpeechRecognizer.ERROR_CLIENT -> "Client error" | |
| 1769 | SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions" | |
| 1770 | SpeechRecognizer.ERROR_NETWORK -> "Network error" | |
| 1771 | SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" | |
| 1772 | SpeechRecognizer.ERROR_NO_MATCH -> "No match" | |
| 1773 | SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" | |
| 1774 | SpeechRecognizer.ERROR_SERVER -> "Server error" | |
| 1775 | SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout" | |
| 1776 | else -> "Unknown error" | |
| 1745 | 1777 | } |
| 1778 | sendEvent("craftSpeechError", mapOf("error" to errorMsg)) | |
| 1779 | haptic("light") | |
| 1780 | } | |
| 1746 | 1781 | |
| 1747 | override fun onEvent(eventType: Int, params: Bundle?) {} | |
| 1748 | }) | |
| 1782 | override fun onResults(results: Bundle?) { | |
| 1783 | isListening = false | |
| 1784 | val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) | |
| 1785 | val transcript = matches?.firstOrNull() ?: "" | |
| 1786 | sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true)) | |
| 1787 | sendEvent("craftSpeechEnd", emptyMap()) | |
| 1788 | haptic("light") | |
| 1789 | } | |
| 1749 | 1790 | |
| 1750 | val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { | |
| 1751 | putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) | |
| 1752 | putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) | |
| 1753 | putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) | |
| 1754 | putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) | |
| 1791 | override fun onPartialResults(partialResults: Bundle?) { | |
| 1792 | val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) | |
| 1793 | val transcript = matches?.firstOrNull() ?: "" | |
| 1794 | sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to false)) | |
| 1755 | 1795 | } |
| 1756 | 1796 | |
| 1757 | speechRecognizer?.startListening(intent) | |
| 1797 | override fun onEvent(eventType: Int, params: Bundle?) {} | |
| 1798 | }) | |
| 1799 | ||
| 1800 | val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { | |
| 1801 | putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) | |
| 1802 | putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) | |
| 1803 | putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) | |
| 1804 | putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) | |
| 1758 | 1805 | } |
| 1806 | ||
| 1807 | speechRecognizer?.startListening(intent) | |
| 1759 | 1808 | } |
| 1760 | 1809 | |
| 1810 | /** | |
| 1811 | * Whether the request was taken. Ungated, as on iOS: stopping something | |
| 1812 | * an app was built without is a no-op, not a refusal, and a page tearing | |
| 1813 | * a screen down should not have to know which build it is running on. | |
| 1814 | */ | |
| 1761 | 1815 | @JavascriptInterface |
| 1762 | fun stopListening() { | |
| 1763 | if (CraftNative.stopListening(activity)) return | |
| 1816 | fun stopListening(): Boolean { | |
| 1817 | if (CraftNative.stopListening(activity)) return true | |
| 1764 | 1818 | |
| 1765 | 1819 | activity.runOnUiThread { |
| 1766 | 1820 | speechRecognizer?.stopListening() |
| 1767 | 1821 | speechRecognizer?.destroy() |
| 1768 | 1822 | speechRecognizer = null |
| 1769 | isListening = false | |
| 1823 | // Only when something was listening, so a page that stops twice, | |
| 1824 | // or stops what never started, does not see a session end twice. | |
| 1825 | // iOS sends this from stopSpeechRecognition for the same reason. | |
| 1826 | if (isListening) { | |
| 1827 | isListening = false | |
| 1828 | sendEvent("craftSpeechEnd", emptyMap()) | |
| 1829 | } | |
| 1770 | 1830 | } |
| 1831 | return true | |
| 1771 | 1832 | } |
| 1772 | 1833 | |
| 1773 | 1834 | // ==================== Share ==================== |
| @@ -2248,6 +2309,12 @@ class CraftBridge( | ||
| 2248 | 2309 | |
| 2249 | 2310 | fun onRequestPermissionsResult(requestCode: Int): Boolean { |
| 2250 | 2311 | if (closed) return requestCode in PERMISSION_REQUEST_START..PERMISSION_REQUEST_END |
| 2312 | // startListening asked for the microphone and is waiting on this. | |
| 2313 | if (requestCode == REQUEST_SPEECH) { | |
| 2314 | if (isPermissionGranted(Manifest.permission.RECORD_AUDIO)) beginSpeechRecognition() | |
| 2315 | else sendEvent("craftSpeechError", mapOf("error" to "Microphone permission denied")) | |
| 2316 | return true | |
| 2317 | } | |
| 2251 | 2318 | val pending = pendingPermissionRequests.remove(requestCode) ?: return false |
| 2252 | 2319 | // Re-read package state instead of interpreting the parallel callback |
| 2253 | 2320 | // arrays. In particular, an approximate-location choice returns a |
| @@ -2826,10 +2893,11 @@ class CraftBridge( | ||
| 2826 | 2893 | |
| 2827 | 2894 | // ==================== Vibration Pattern ==================== |
| 2828 | 2895 | |
| 2896 | /** Whether the pattern played, the same answer haptic() gives (#219). */ | |
| 2829 | 2897 | @JavascriptInterface |
| 2830 | fun vibrate(patternJson: String) { | |
| 2831 | if (!hapticsEnabled && disabled("haptics", "vibrate")) return | |
| 2832 | if (CraftNative.vibrate(activity, patternJson)) return | |
| 2898 | fun vibrate(patternJson: String): Boolean { | |
| 2899 | if (!hapticsEnabled && disabled("haptics", "vibrate")) return false | |
| 2900 | if (CraftNative.vibrate(activity, patternJson)) return true | |
| 2833 | 2901 | |
| 2834 | 2902 | val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
| 2835 | 2903 | val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager |
| @@ -2850,8 +2918,10 @@ class CraftBridge( | ||
| 2850 | 2918 | @Suppress("DEPRECATION") |
| 2851 | 2919 | vibrator.vibrate(timings, -1) |
| 2852 | 2920 | } |
| 2921 | return true | |
| 2853 | 2922 | } catch (e: Exception) { |
| 2854 | 2923 | log("Vibration error: ${e.message}") |
| 2924 | return false | |
| 2855 | 2925 | } |
| 2856 | 2926 | } |
| 2857 | 2927 | |
| @@ -4950,6 +5020,7 @@ class CraftBridge( | ||
| 4950 | 5020 | const val REQUEST_BLUETOOTH = 1009 |
| 4951 | 5021 | const val REQUEST_PICK_CONTACT = 1010 |
| 4952 | 5022 | const val REQUEST_SHARE = 1011 |
| 5023 | const val REQUEST_SPEECH = 1012 | |
| 4953 | 5024 | private const val PERMISSION_REQUEST_START = 2000 |
| 4954 | 5025 | private const val PERMISSION_REQUEST_END = 2999 |
| 4955 | 5026 | private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L |
| @@ -114,11 +114,13 @@ export interface CraftBridge { | ||
| 114 | 114 | /** |
| 115 | 115 | * Trigger haptic feedback |
| 116 | 116 | * @param style - Feedback style: 'light', 'medium', 'heavy', 'success', 'warning', 'error', 'selection' |
| 117 | * @returns On iOS, a promise that resolves `true` once UIKit has taken the | |
| 118 | * haptic, and rejects with `CAPABILITY_DISABLED` when `enableHaptics` is off. | |
| 119 | * Android returns nothing yet (#219). | |
| 117 | * @returns A promise that rejects with `CAPABILITY_DISABLED` when | |
| 118 | * `enableHaptics` is off. Otherwise it resolves `true` on iOS once UIKit has | |
| 119 | * taken the haptic, and on Android whether the device played it — Android | |
| 120 | * answers `false` for a vibrator that refused, which is not an error | |
| 121 | * because a haptic decorates a flow rather than carries it. | |
| 120 | 122 | */ |
| 121 | haptic(style: HapticStyle): Promise<boolean> | void; | |
| 123 | haptic(style: HapticStyle): Promise<boolean>; | |
| 122 | 124 | |
| 123 | 125 | /** |
| 124 | 126 | * Open the native share sheet. |
| @@ -192,20 +194,23 @@ export interface CraftBridge { | ||
| 192 | 194 | |
| 193 | 195 | /** |
| 194 | 196 | * Start speech recognition |
| 195 | * @returns On iOS, a promise that resolves `true` once native has taken the | |
| 197 | * @returns A promise that resolves `true` once native has taken the | |
| 196 | 198 | * request, and rejects with `CAPABILITY_DISABLED` when |
| 197 | 199 | * `enableSpeechRecognition` is off. `true` does not mean audio is flowing: |
| 198 | 200 | * a declined prompt or a missing recognizer still arrives as |
| 199 | * `craftSpeechError`, and transcripts as `craftSpeechResult`. Android returns | |
| 200 | * nothing yet (#219). | |
| 201 | * `craftSpeechError`, and transcripts as `craftSpeechResult`. Android answers | |
| 202 | * the same way, including while it asks for the microphone: it starts once | |
| 203 | * that is granted, and sends `craftSpeechError` when it is not. | |
| 201 | 204 | */ |
| 202 | startListening(): Promise<boolean> | void; | |
| 205 | startListening(): Promise<boolean>; | |
| 203 | 206 | |
| 204 | 207 | /** |
| 205 | 208 | * Stop speech recognition |
| 206 | * @returns On iOS, a promise that resolves `true`. Android returns nothing yet (#219). | |
| 209 | * @returns A promise that resolves `true`. Never refused on either | |
| 210 | * platform: stopping what an app was built without is a no-op, so a page | |
| 211 | * tearing a screen down need not know which build it is running on. | |
| 207 | 212 | */ |
| 208 | stopListening(): Promise<boolean> | void; | |
| 213 | stopListening(): Promise<boolean>; | |
| 209 | 214 | |
| 210 | 215 | // ==================== Files ==================== |
| 211 | 216 | |
| @@ -488,10 +493,11 @@ export interface CraftBridge { | ||
| 488 | 493 | /** |
| 489 | 494 | * Vibrate with pattern |
| 490 | 495 | * @param pattern - Array of durations in ms [on, off, on, off, ...] |
| 491 | * @returns On iOS, a promise that resolves `true` once the pattern is | |
| 492 | * scheduled. Android returns nothing yet (#219). | |
| 496 | * @returns A promise that rejects with `CAPABILITY_DISABLED` when | |
| 497 | * `enableHaptics` is off, resolves `true` on iOS once the pattern is | |
| 498 | * scheduled, and on Android whether the device played it. | |
| 493 | 499 | */ |
| 494 | vibrate(pattern: number[]): Promise<boolean> | void; | |
| 500 | vibrate(pattern: number[]): Promise<boolean>; | |
| 495 | 501 | |
| 496 | 502 | /** |
| 497 | 503 | * Open URL in external browser |
| @@ -33,6 +33,9 @@ const CONFIG = { | ||
| 33 | 33 | enablePushNotifications: false, |
| 34 | 34 | enableSecureStorage: false, |
| 35 | 35 | enableShare: true, |
| 36 | // On, so haptic and vibrate have a success path to answer from (#219), | |
| 37 | // and speech stays off so startListening has a refusal to carry. | |
| 38 | enableHaptics: true, | |
| 36 | 39 | enableGeolocation: true, |
| 37 | 40 | enableDeepLinks: true, |
| 38 | 41 | urlSchemes: [DEEP_LINK_SCHEME], |
| @@ -250,8 +250,10 @@ | ||
| 250 | 250 | // Haptics is on, and native used to answer an enabled haptic with |
| 251 | 251 | // nothing at all; it only ever rejected. So a page that awaited it |
| 252 | 252 | // would wait for ever on every call that worked. |
| 253 | // | |
| 254 | // Android answered nothing at all until #219, on every path. | |
| 253 | 255 | name: 'haptic.resolvesTrue', |
| 254 | platforms: ['ios'], | |
| 256 | platforms: ['ios', 'android'], | |
| 255 | 257 | run: function () { |
| 256 | 258 | return withTimeout(function () { return window.craft.haptic('light') }).then(function (outcome) { |
| 257 | 259 | if (outcome.state === 'timeout') return 'haptic never settled with haptics enabled' |
| @@ -262,9 +264,13 @@ | ||
| 262 | 264 | }, |
| 263 | 265 | { |
| 264 | 266 | name: 'vibrate.resolvesTrue', |
| 265 | platforms: ['ios'], | |
| 267 | platforms: ['ios', 'android'], | |
| 266 | 268 | run: function () { |
| 267 | return withTimeout(function () { return window.craft.vibrate([]) }).then(function (outcome) { | |
| 269 | // Android hands the pattern to VibrationEffect.createWaveform, | |
| 270 | // which rejects an empty one, so the two platforms send what each | |
| 271 | // actually accepts rather than agreeing on a value neither uses. | |
| 272 | var pattern = EXPECTED_PLATFORM === 'android' ? [100, 50, 100] : [] | |
| 273 | return withTimeout(function () { return window.craft.vibrate(pattern) }).then(function (outcome) { | |
| 268 | 274 | if (outcome.state === 'timeout') return 'vibrate never settled' |
| 269 | 275 | if (outcome.state === 'rejected') return 'vibrate rejected: ' + outcome.error.code + ' ' + outcome.error.message |
| 270 | 276 | if (outcome.value !== true) return 'vibrate resolved with ' + JSON.stringify(outcome.value) |
| @@ -276,8 +282,11 @@ | ||
| 276 | 282 | // refusal: the page got undefined and no craftSpeechError either. |
| 277 | 283 | // Refused before any authorization prompt, so nothing waits on a |
| 278 | 284 | // microphone the simulator does not have. |
| 285 | // | |
| 286 | // Android refuses from the same gate since #209, but could not carry | |
| 287 | // the refusal until #219 gave the call a promise to reject. | |
| 279 | 288 | name: 'speech.disabled.rejects', |
| 280 | platforms: ['ios'], | |
| 289 | platforms: ['ios', 'android'], | |
| 281 | 290 | run: function () { |
| 282 | 291 | return withTimeout(function () { return window.craft.startListening() }).then(function (outcome) { |
| 283 | 292 | if (outcome.state === 'timeout') return 'startListening never settled' |
| @@ -287,11 +296,12 @@ | ||
| 287 | 296 | } |
| 288 | 297 | }, |
| 289 | 298 | { |
| 290 | // stopListening is not gated, so it runs with speech off, and it is | |
| 291 | // the one speech call whose success path a simulator can reach | |
| 292 | // without a microphone. It used to answer nothing on either side. | |
| 299 | // stopListening is not gated on either platform, so it runs with | |
| 300 | // speech off, and it is the one speech call whose success path a | |
| 301 | // simulator or emulator can reach without a microphone. It used to | |
| 302 | // answer nothing on either side. | |
| 293 | 303 | name: 'speech.stopListening.resolvesTrue', |
| 294 | platforms: ['ios'], | |
| 304 | platforms: ['ios', 'android'], | |
| 295 | 305 | run: function () { |
| 296 | 306 | return withTimeout(function () { return window.craft.stopListening() }).then(function (outcome) { |
| 297 | 307 | if (outcome.state === 'timeout') return 'stopListening never settled' |
| @@ -97,6 +97,10 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 97 | 97 | 'share.dismissed.resolvesFalse', |
| 98 | 98 | 'permissions.location.granted', |
| 99 | 99 | 'capabilities.match.behaviour', |
| 100 | 'haptic.resolvesTrue', | |
| 101 | 'vibrate.resolvesTrue', | |
| 102 | 'speech.disabled.rejects', | |
| 103 | 'speech.stopListening.resolvesTrue', | |
| 100 | 104 | ], |
| 101 | 105 | } |
| 102 | 106 | |