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

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.

packages/android/src/index.test.tsmodified+36-0
Changes to packages/android/src/index.test.ts
@@ -377,6 +377,42 @@ describe('Craft Android builder', () => {
377377 expect(bridge).toContain('if (!keepAwakeEnabled && disabled("keepAwake", "setKeepAwake")) return false')
378378 })
379379
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
380416 it('routes external Activity results back to every pending media promise', async () => {
381417 const output = mkdtempSync(join(tmpdir(), 'craft-android-activity-results-'))
382418 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output })
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[] = []
packages/android/templates/CraftBridge.kt.templatemodified+152-81
Changes to packages/android/templates/CraftBridge.kt.template
@@ -381,17 +381,29 @@ class CraftBridge(
381381 screenCapture: false
382382 },
383383
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.
385397 haptic: function(style) {
386 CraftAndroid.haptic(style || 'medium');
398 return Promise.resolve().then(function() { return CraftAndroid.haptic(style || 'medium') === true; });
387399 },
388400
389401 // Speech recognition
390402 startListening: function() {
391 CraftAndroid.startListening();
403 return Promise.resolve().then(function() { return CraftAndroid.startListening() === true; });
392404 },
393405 stopListening: function() {
394 CraftAndroid.stopListening();
406 return Promise.resolve().then(function() { return CraftAndroid.stopListening() === true; });
395407 },
396408
397409 // Share. Resolves true when the person picks an app, false
@@ -526,7 +538,7 @@ class CraftBridge(
526538
527539 // Vibration Pattern
528540 vibrate: function(pattern) {
529 CraftAndroid.vibrate(JSON.stringify(pattern));
541 return Promise.resolve().then(function() { return CraftAndroid.vibrate(JSON.stringify(pattern)) === true; });
530542 },
531543
532544 // App State
@@ -1390,18 +1402,18 @@ class CraftBridge(
13901402 //
13911403 // The refusal is made here, from the same capabilities object the
13921404 // 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).
13961409 //
13971410 // Kotlin refuses the ungated-by-Android ones a second time, for a
13981411 // caller that reaches CraftAndroid without going through here.
13991412 (function installCapabilityGates(craft) {
14001413 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],
14051417 ['setFlashlight', 'flashlight'],
14061418 ['toggleFlashlight', 'flashlight'],
14071419 ['setKeepAwake', 'keepAwake'],
@@ -1512,11 +1524,26 @@ class CraftBridge(
15121524 getInfo: function() { return Promise.resolve().then(function() { return craft.getDeviceInfo(); }); },
15131525 getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
15141526 };
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 }
15151540 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 || [])); }
15201547 };
15211548 craft.camera = {
15221549 takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
@@ -1647,10 +1674,16 @@ class CraftBridge(
16471674
16481675 // ==================== Haptics ====================
16491676
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 */
16501683 @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
16541687
16551688 val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
16561689 val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -1676,98 +1709,126 @@ class CraftBridge(
16761709 @Suppress("DEPRECATION")
16771710 vibrator.vibrate(20)
16781711 }
1712 return true
16791713 }
16801714
16811715 // ==================== Speech Recognition ====================
16821716
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 */
16831726 @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
16871730
16881731 if (!SpeechRecognizer.isRecognitionAvailable(activity)) {
16891732 sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available"))
1690 return
1733 return false
16911734 }
16921735
16931736 activity.runOnUiThread {
16941737 if (closed) return@runOnUiThread
16951738 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
16961739 != PackageManager.PERMISSION_GRANTED) {
1697 requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1740 requestPermissionsBestEffort(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_SPEECH)
16981741 return@runOnUiThread
16991742 }
1743 beginSpeechRecognition()
1744 }
1745 return true
1746 }
17001747
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 }
17401758
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"
17451777 }
1778 sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1779 haptic("light")
1780 }
17461781
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 }
17491790
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))
17551795 }
17561796
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)
17581805 }
1806
1807 speechRecognizer?.startListening(intent)
17591808 }
17601809
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 */
17611815 @JavascriptInterface
1762 fun stopListening() {
1763 if (CraftNative.stopListening(activity)) return
1816 fun stopListening(): Boolean {
1817 if (CraftNative.stopListening(activity)) return true
17641818
17651819 activity.runOnUiThread {
17661820 speechRecognizer?.stopListening()
17671821 speechRecognizer?.destroy()
17681822 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 }
17701830 }
1831 return true
17711832 }
17721833
17731834 // ==================== Share ====================
@@ -2248,6 +2309,12 @@ class CraftBridge(
22482309
22492310 fun onRequestPermissionsResult(requestCode: Int): Boolean {
22502311 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 }
22512318 val pending = pendingPermissionRequests.remove(requestCode) ?: return false
22522319 // Re-read package state instead of interpreting the parallel callback
22532320 // arrays. In particular, an approximate-location choice returns a
@@ -2826,10 +2893,11 @@ class CraftBridge(
28262893
28272894 // ==================== Vibration Pattern ====================
28282895
2896 /** Whether the pattern played, the same answer haptic() gives (#219). */
28292897 @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
28332901
28342902 val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
28352903 val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
@@ -2850,8 +2918,10 @@ class CraftBridge(
28502918 @Suppress("DEPRECATION")
28512919 vibrator.vibrate(timings, -1)
28522920 }
2921 return true
28532922 } catch (e: Exception) {
28542923 log("Vibration error: ${e.message}")
2924 return false
28552925 }
28562926 }
28572927
@@ -4950,6 +5020,7 @@ class CraftBridge(
49505020 const val REQUEST_BLUETOOTH = 1009
49515021 const val REQUEST_PICK_CONTACT = 1010
49525022 const val REQUEST_SHARE = 1011
5023 const val REQUEST_SPEECH = 1012
49535024 private const val PERMISSION_REQUEST_START = 2000
49545025 private const val PERMISSION_REQUEST_END = 2999
49555026 private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
packages/typescript/types/craft.d.tsmodified+19-13
Changes to packages/typescript/types/craft.d.ts
@@ -114,11 +114,13 @@ export interface CraftBridge {
114114 /**
115115 * Trigger haptic feedback
116116 * @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.
120122 */
121 haptic(style: HapticStyle): Promise<boolean> | void;
123 haptic(style: HapticStyle): Promise<boolean>;
122124
123125 /**
124126 * Open the native share sheet.
@@ -192,20 +194,23 @@ export interface CraftBridge {
192194
193195 /**
194196 * 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
196198 * request, and rejects with `CAPABILITY_DISABLED` when
197199 * `enableSpeechRecognition` is off. `true` does not mean audio is flowing:
198200 * 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.
201204 */
202 startListening(): Promise<boolean> | void;
205 startListening(): Promise<boolean>;
203206
204207 /**
205208 * 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.
207212 */
208 stopListening(): Promise<boolean> | void;
213 stopListening(): Promise<boolean>;
209214
210215 // ==================== Files ====================
211216
@@ -488,10 +493,11 @@ export interface CraftBridge {
488493 /**
489494 * Vibrate with pattern
490495 * @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.
493499 */
494 vibrate(pattern: number[]): Promise<boolean> | void;
500 vibrate(pattern: number[]): Promise<boolean>;
495501
496502 /**
497503 * Open URL in external browser
scripts/mobile-e2e/android.tsmodified+3-0
Changes to scripts/mobile-e2e/android.ts
@@ -33,6 +33,9 @@ const CONFIG = {
3333 enablePushNotifications: false,
3434 enableSecureStorage: false,
3535 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,
3639 enableGeolocation: true,
3740 enableDeepLinks: true,
3841 urlSchemes: [DEEP_LINK_SCHEME],
scripts/mobile-e2e/driver.htmlmodified+18-8
Changes to scripts/mobile-e2e/driver.html
@@ -250,8 +250,10 @@
250250 // Haptics is on, and native used to answer an enabled haptic with
251251 // nothing at all; it only ever rejected. So a page that awaited it
252252 // would wait for ever on every call that worked.
253 //
254 // Android answered nothing at all until #219, on every path.
253255 name: 'haptic.resolvesTrue',
254 platforms: ['ios'],
256 platforms: ['ios', 'android'],
255257 run: function () {
256258 return withTimeout(function () { return window.craft.haptic('light') }).then(function (outcome) {
257259 if (outcome.state === 'timeout') return 'haptic never settled with haptics enabled'
@@ -262,9 +264,13 @@
262264 },
263265 {
264266 name: 'vibrate.resolvesTrue',
265 platforms: ['ios'],
267 platforms: ['ios', 'android'],
266268 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) {
268274 if (outcome.state === 'timeout') return 'vibrate never settled'
269275 if (outcome.state === 'rejected') return 'vibrate rejected: ' + outcome.error.code + ' ' + outcome.error.message
270276 if (outcome.value !== true) return 'vibrate resolved with ' + JSON.stringify(outcome.value)
@@ -276,8 +282,11 @@
276282 // refusal: the page got undefined and no craftSpeechError either.
277283 // Refused before any authorization prompt, so nothing waits on a
278284 // 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.
279288 name: 'speech.disabled.rejects',
280 platforms: ['ios'],
289 platforms: ['ios', 'android'],
281290 run: function () {
282291 return withTimeout(function () { return window.craft.startListening() }).then(function (outcome) {
283292 if (outcome.state === 'timeout') return 'startListening never settled'
@@ -287,11 +296,12 @@
287296 }
288297 },
289298 {
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.
293303 name: 'speech.stopListening.resolvesTrue',
294 platforms: ['ios'],
304 platforms: ['ios', 'android'],
295305 run: function () {
296306 return withTimeout(function () { return window.craft.stopListening() }).then(function (outcome) {
297307 if (outcome.state === 'timeout') return 'stopListening never settled'
scripts/mobile-e2e/protocol.tsmodified+4-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -97,6 +97,10 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
9797 'share.dismissed.resolvesFalse',
9898 'permissions.location.granted',
9999 'capabilities.match.behaviour',
100 'haptic.resolvesTrue',
101 'vibrate.resolvesTrue',
102 'speech.disabled.rejects',
103 'speech.stopListening.resolvesTrue',
100104 ],
101105}
102106