Repository navigation
feat(mobile): search by voice with the phone's on-device recogniser - #774
Merged
Merged
Conversation
The installed app now shows the website's voice search button and runs recognition on the phone through expo-speech-recognition 57.1.1. It is enabled only on Android 13+, where the package uses createOnDeviceSpeechRecognizer with no network fallback; iOS stays off because the package can fall back to server recognition there. The web button and its states are unchanged. In the shell it talks to native over new bridge messages (voice.start/stop/cancel, voice.event) and learns availability with a voice.probe request after the handshake, so native.hello stays compatible with the deployed web and older APKs simply leave the probe unanswered. Native listening is a single utterance capped at 15 s, cancelled when the app leaves the foreground (but not by the OS permission prompt), on page reload or load failure, and on teardown. Late results are dropped on both sides, a recogniser that never reports its end cannot leak into the next session, and guidance cues are held while listening and spoken afterwards only if still current. The WebView never gets the microphone: react-native-webview is patched to deny audio and video capture. A second patch removes transcript logging from the speech package. Docker images copy the patches, which pnpm needs to verify the lockfile. Refs #405
The page could start the microphone without anyone tapping. Recordings never reach the page, but a script injected into it could have started listening in a loop and read the transcripts. The shell now accepts voice.start only within three seconds of a touch it saw on its own view tree, one start per touch, and answers anything else with an error without recording. With a screen reader on the touch requirement is waived, since activation then arrives as an accessibility action. Refs #405
The package's iOS path set requiresOnDeviceRecognition only when the recogniser supported it and otherwise fell back to Apple's servers (jamsch/expo-speech-recognition#169). Our patch now checks the requested locale before touching the microphone, checks again before capture, sets requiresOnDeviceRecognition on every request, and reports language-not-supported instead of falling back. The availability check takes a locale, so the probe reflects German and English rather than the phone's default language. On iOS the controller asks for microphone and speech-recognition authorization, hands the audio session back after listening (deactivating with notifyOthersOnDeactivation unless guidance is speaking) before a held cue plays, and shows iPhone-specific warnings chosen from the handshake's platform. Android behaviour and wording are unchanged. Refs #405
Medformatik
force-pushed
the
feat/voice-search-native
branch
from
November 4, 2026 23:59
97cf1d4 to
0acc380
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
The installed app hid the voice search button the website shows, because the WebView has no microphone and browser recognition would send audio to a cloud service. The app now shows the same button on Android and iPhone and recognises speech on the phone. The button, its listening state and its warnings are the website's; only the engine underneath changes. Interim text fills the search field and the final transcript searches, as on the website.
Recognition uses
expo-speech-recognition57.1.1 (MIT), a wrapper around Android'sSpeechRecognizerand Apple'sSFSpeechRecognizer. Recording never goes to a server recogniser on either platform:requiresOnDeviceRecognition: truemakes the package callcreateOnDeviceSpeechRecognizer, which has no network fallback. On Android 11–12 the package would only prefer offline recognition, so the button stays hidden there.requiresOnDeviceRecognitiononly when the recogniser supported it and otherwise fell back to Apple's servers (jamsch/expo-speech-recognition#169). Our patch checks the requested language before the microphone is touched and again right before recording, always sets the flag, and reports "language not supported" instead of falling back. The availability check takes a language, so the button reflects German and English rather than the phone's default language.The decision record, including the packages that were rejected, is in
docs/docs/developer/voice-input.md.Android
The first tap shows Android's own microphone prompt. A refusal shows the existing warning, now pointing to Android settings:
If the requested language isn't installed for offline recognition, the existing warning says so and Android's own language download is triggered (here German on an emulator that only has English):
iPhone
The first tap asks for the microphone, then for speech recognition, each with our explanation (iOS 26.4 simulator):
The second alert carries Apple's fixed text, "Speech data from this app will be sent to Apple to process your requests", followed by ours. Apple shows that text for every speech-recognition request, regardless of
requiresOnDeviceRecognition, so here it contradicts what the app does. The package itself skips this permission for on-device recognition, but Apple's guidance doesn't exempt that path, so the app requests it for now. If a real iPhone shows that on-device recognition works without it, the follow-up is to stop requesting it so the alert never appears.A refusal of either permission shows the existing warning pointing to iPhone Settings > Privacy & Security. A language the iPhone can't recognise offline shows a warning pointing to the keyboard and Dictation settings, without a prompt or recording. iOS has no download trigger like Android's. After listening, the audio session is handed back (deactivated with
notifyOthersOnDeactivationunless guidance is speaking) before a held guidance cue plays, so other audio resumes and guidance configures its own session.Both platforms
voice.start,voice.stopandvoice.canceland receivesvoice.event(listening, partial, final, ended, error). It learns availability with avoice.proberequest after the handshake.native.hellois unchanged, so a new APK still negotiates with the deployed website, and an older APK leaves the probe unanswered and the button stays hidden. No protocol version change. The handshake's existingplatformpicks the Android or iPhone wording.voice.startonly within 3 seconds of a touch it observed on its own view, one start per touch, so a script injected into the page cannot listen in a loop. With a screen reader on, activation arrives as an accessibility action rather than a touch, so the requirement is waived there. The operating system's own microphone indicator, which the page cannot hide, shows whenever the recogniser holds the microphone.react-native-webviewis patched to deny audio and video capture; without that patch it would grant the page the microphone once the app holdsRECORD_AUDIO. iOS keepsmediaCapturePermissionGrantType="deny". The speech package patch also removes three log lines that wrote transcripts to Logcat. Recordings are not persisted. The privacy pages, the compliance registry (voice-search-audio) and the store worksheets describe the practice for both platforms.patches/beforepnpm install. Without it they fail with "Patch file not found" (reproduced locally).The issue proposed a rationale screen and a settings button before the OS prompt. Both are left out because this change adds no UI; the OS prompt follows the tap that asks for it.
Related work
Refs #405. This does not close it: accuracy and latency on a consented recording set and device qualification on physical phones remain open.
Found while testing, not part of this change: the app does not start on the iOS 27 simulator ("Application failed to launch: UIScene life cycle is required"). Apps built with the iOS 27 SDK must adopt the scene-based life cycle, and the
AppDelegateour Expo template generates doesn't yet, although Expo 57 already shipsExpoAppSceneDelegate. That affects the whole iPhone app and needs its own fix.Verification
tsc --noEmitfor core, web and mobile; Biome on changed files; Prettier on docs; store worksheets--check; the fullcheck:policy;assert-no-community-runtime(checks the WebView patch and both the Android log removals and the iOS fail-closed guards in the installed speech package); clean Android and iOS prebuilds (the Android manifest adds onlyRECORD_AUDIOand theRecognitionServicequery; the iOS Info.plist carries both localized usage strings).getUserMedia({ audio: true })from the page was refused while the app heldRECORD_AUDIO;voice.startposted from the page without a touch was answered with an error and recorded nothing, while a real tap on the button went through.en-USonly, and the button appears;Release and upgrade impact
No package release: This adds bridge messages to the private
@openmapx/coreprotocol used between the web app and the installed shell, and the lockfile and workspace changes add a mobile-only dependency and patches for two mobile-only packages; the published extension packages' files and contracts stay the same.Deploy order does not matter: the deployed website ignores the new APK's capabilities, and older APKs never answer the probe.