From a04da36a2e81b2654165e10576e4ac80281d1105 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 14:58:57 +0100 Subject: [PATCH 1/5] feat(mobile): search by voice with the phone's on-device recogniser 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 --- apps/api/Dockerfile | 3 + apps/mobile/app.config.ts | 12 +- .../compliance/apple-privacy-worksheet.md | 16 + apps/mobile/compliance/data-practices.json | 31 ++ apps/mobile/compliance/data-practices.test.ts | 1 + .../google-data-safety-worksheet.md | 16 + apps/mobile/compliance/permissions.test.ts | 9 +- apps/mobile/compliance/permissions.ts | 21 +- apps/mobile/compliance/store-answers.mts | 23 ++ apps/mobile/config/locales/de.json | 4 +- apps/mobile/config/locales/en.json | 4 +- apps/mobile/config/nativeCopy.ts | 12 + apps/mobile/package.json | 1 + apps/mobile/plugins/nativeConfigTransforms.ts | 10 +- .../plugins/withOpenMapXNativeConfig.test.ts | 32 ++ .../plugins/withOpenMapXNativeConfig.ts | 13 + .../scripts/assert-no-community-runtime.mts | 26 +- .../scripts/assert-release-permissions.mts | 2 +- .../scripts/clean-prebuild-check.test.ts | 7 +- .../scripts/generatedNativeChecks.test.ts | 26 +- apps/mobile/scripts/generatedNativeChecks.ts | 20 +- .../scripts/webViewCapturePolicy.test.ts | 38 ++ apps/mobile/scripts/webViewCapturePolicy.ts | 14 + apps/mobile/src/App.test.tsx | 80 +++- apps/mobile/src/App.tsx | 61 ++- .../mobile/src/audio/navigationAudioModule.ts | 11 +- apps/mobile/src/bridge/nativeBridge.test.ts | 14 + apps/mobile/src/bridge/nativeBridge.ts | 1 + .../src/navigation/NavigationCoordinator.ts | 6 + .../src/voice/VoiceInputController.test.ts | 328 ++++++++++++++++ apps/mobile/src/voice/VoiceInputController.ts | 366 ++++++++++++++++++ apps/mobile/src/voice/audioArbiter.test.ts | 85 ++++ apps/mobile/src/voice/audioArbiter.ts | 75 ++++ apps/ops-agent/Dockerfile | 2 + apps/transitous-runner/Dockerfile | 2 + apps/web/Dockerfile | 2 + .../src/app/(legal)/privacy/content.de.tsx | 35 +- .../src/app/(legal)/privacy/content.en.tsx | 35 +- .../search/VoiceSearchButton.test.tsx | 204 +++++++++- .../components/search/VoiceSearchButton.tsx | 44 ++- apps/web/src/lib/mobile/bridgeClient.test.ts | 78 ++++ apps/web/src/lib/mobile/bridgeClient.ts | 1 + .../src/lib/mobile/mobileShellEnvironment.ts | 7 +- .../src/lib/mobile/nativeVoiceInput.test.ts | 78 ++++ apps/web/src/lib/mobile/nativeVoiceInput.ts | 70 ++++ .../web/src/lib/mobile/useNativeVoiceInput.ts | 38 ++ docs/docs/developer/mobile-architecture.md | 47 ++- docs/docs/developer/mobile-feasibility.md | 15 + docs/docs/developer/mobile-release.md | 13 +- .../docs/developer/mobile-store-operations.md | 17 + docs/docs/developer/voice-input.md | 142 +++++++ docs/docs/features/search.md | 26 +- docs/sidebars.ts | 1 + packages/core/src/navigation/index.ts | 2 + .../src/navigation/mobileProtocol.test.ts | 117 ++++++ .../core/src/navigation/mobileProtocol.ts | 56 ++- packages/i18n/locales/de.json | 2 + packages/i18n/locales/en.json | 2 + patches/expo-speech-recognition@57.1.1.patch | 31 ++ patches/react-native-webview@13.16.1.patch | 20 + pnpm-lock.yaml | 184 +++++---- pnpm-workspace.yaml | 3 + services/data-manager/Dockerfile | 2 + 63 files changed, 2463 insertions(+), 181 deletions(-) create mode 100644 apps/mobile/scripts/webViewCapturePolicy.test.ts create mode 100644 apps/mobile/scripts/webViewCapturePolicy.ts create mode 100644 apps/mobile/src/voice/VoiceInputController.test.ts create mode 100644 apps/mobile/src/voice/VoiceInputController.ts create mode 100644 apps/mobile/src/voice/audioArbiter.test.ts create mode 100644 apps/mobile/src/voice/audioArbiter.ts create mode 100644 apps/web/src/lib/mobile/nativeVoiceInput.test.ts create mode 100644 apps/web/src/lib/mobile/nativeVoiceInput.ts create mode 100644 apps/web/src/lib/mobile/useNativeVoiceInput.ts create mode 100644 docs/docs/developer/voice-input.md create mode 100644 patches/expo-speech-recognition@57.1.1.patch create mode 100644 patches/react-native-webview@13.16.1.patch diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 02ee89e50..b103ad579 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,6 +12,8 @@ RUN corepack enable && \ FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +# pnpm checks every patched dependency's file hash, installed here or not. +COPY patches/ patches/ COPY packages/core/package.json packages/core/ COPY packages/extension-contracts/package.json packages/extension-contracts/ COPY packages/db-schema/package.json packages/db-schema/ @@ -44,6 +46,7 @@ RUN pnpm install --frozen-lockfile FROM base AS prod-deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY patches/ patches/ COPY packages/core/package.json packages/core/ COPY packages/extension-contracts/package.json packages/extension-contracts/ COPY packages/db-schema/package.json packages/db-schema/ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 49e0e7087..b27c22f78 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -74,6 +74,7 @@ const config: ExpoConfig = { backgroundColor: BRAND_COLOR, }, permissions: [ + "android.permission.RECORD_AUDIO", "android.permission.INTERNET", "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", @@ -92,13 +93,13 @@ const config: ExpoConfig = { "android.permission.RECEIVE_BOOT_COMPLETED", ], // Permissions some bundled Expo modules declare by default but this app - // must not ship: no remote push, no exact alarms, no media/microphone + // must not ship: no remote push, no exact alarms, no media/background-microphone // capability. blockedPermissions: [ "com.google.android.c2dm.permission.RECEIVE", "android.permission.SCHEDULE_EXACT_ALARM", "android.permission.USE_EXACT_ALARM", - "android.permission.RECORD_AUDIO", + "android.permission.FOREGROUND_SERVICE_MICROPHONE", "android.permission.CAMERA", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", @@ -167,6 +168,13 @@ const config: ExpoConfig = { enableBackgroundRemoteNotifications: false, }, ], + [ + "expo-speech-recognition", + { + microphonePermission: IOS_USAGE_DESCRIPTIONS.en.NSMicrophoneUsageDescription, + speechRecognitionPermission: IOS_USAGE_DESCRIPTIONS.en.NSSpeechRecognitionUsageDescription, + }, + ], "expo-system-ui", ], // Non-secret build inputs the running app reads through `expo-constants`. diff --git a/apps/mobile/compliance/apple-privacy-worksheet.md b/apps/mobile/compliance/apple-privacy-worksheet.md index cdaba77e6..9f1ab169c 100644 --- a/apps/mobile/compliance/apple-privacy-worksheet.md +++ b/apps/mobile/compliance/apple-privacy-worksheet.md @@ -36,6 +36,22 @@ below remain pending review; omitted rows do not establish an exemption. | User Content | Review text, rating, nickname, photos and public signing identity the user chooses to publish | App Functionality | No | No | | Coarse Location | Tile and imagery requests revealing the viewed area, with network metadata such as IP address | App Functionality | No | No | +## On-device voice-search audio + +Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted. + +Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules. + +User control: Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings. + +Proposed collection: Apple No; Google No. +Proposed Google sharing: No. +Purpose: App functionality. + +Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. + +These are design classifications, pending candidate device/privacy qualification. + ## Tracking Proposed answer: No. Verify the exact binary's dependencies and privacy manifests, diff --git a/apps/mobile/compliance/data-practices.json b/apps/mobile/compliance/data-practices.json index 955d5e44a..d23dc1c6b 100644 --- a/apps/mobile/compliance/data-practices.json +++ b/apps/mobile/compliance/data-practices.json @@ -500,6 +500,37 @@ "legalSection": "third-parties", "notes": "Pending review of distributor handling, developer access and the exact binary SDK inventory. The retained not-collected values do not establish a real-time-processing exemption or prove that the operator receives no store diagnostics.", "encryptedInTransit": true + }, + { + "id": "voice-search-audio", + "dataType": "Microphone audio for user-initiated on-device voice search", + "source": "device-sensor", + "leavesDevice": false, + "purpose": "Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted.", + "linkedToAccount": false, + "retention": { + "kind": "ephemeral", + "note": "Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules." + }, + "shared": "none", + "userControl": "Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings.", + "appleLabel": { + "collected": false, + "category": "Audio Data", + "purposes": ["App Functionality"], + "linkedToIdentity": false, + "usedForTracking": false + }, + "googleDataSafety": { + "collected": false, + "shared": false, + "category": "Audio files", + "ephemeral": true, + "optional": true, + "purposes": ["App functionality"] + }, + "legalSection": "Voice Search (Microphone)", + "notes": "Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it." } ] } diff --git a/apps/mobile/compliance/data-practices.test.ts b/apps/mobile/compliance/data-practices.test.ts index f31f6b002..cbee86975 100644 --- a/apps/mobile/compliance/data-practices.test.ts +++ b/apps/mobile/compliance/data-practices.test.ts @@ -63,6 +63,7 @@ const byId = new Map(practices.map((practice) => [practice.id, practice])); /** Every behaviour a reviewer would look for. A missing row is a missing answer. */ const REQUIRED_PRACTICES = [ + "voice-search-audio", "precise-location-active-navigation", "background-location-active-navigation", "route-request-coordinates", diff --git a/apps/mobile/compliance/google-data-safety-worksheet.md b/apps/mobile/compliance/google-data-safety-worksheet.md index 330df4cef..5c307c8f7 100644 --- a/apps/mobile/compliance/google-data-safety-worksheet.md +++ b/apps/mobile/compliance/google-data-safety-worksheet.md @@ -34,6 +34,22 @@ also be reviewed; their absence does not establish that data is not collected. | App activity | Review text, rating, nickname, photos and public signing identity the user chooses to publish | Shared | Retained | Optional | App functionality | | Location | Tile and imagery requests revealing the viewed area, with network metadata such as IP address | Shared | Processed ephemerally | Required | App functionality | +## On-device voice-search audio + +Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted. + +Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules. + +User control: Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings. + +Proposed collection: Apple No; Google No. +Proposed Google sharing: No. +Purpose: App functionality. + +Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. + +These are design classifications, pending candidate device/privacy qualification. + ## Security practices to verify - Verify HTTPS for the binary, served web code and every configured provider path. diff --git a/apps/mobile/compliance/permissions.test.ts b/apps/mobile/compliance/permissions.test.ts index 2a38f56e0..10760aaaa 100644 --- a/apps/mobile/compliance/permissions.test.ts +++ b/apps/mobile/compliance/permissions.test.ts @@ -37,8 +37,10 @@ describe("every permission is justified", () => { }); describe("the permitted surface is minimal", () => { - it("asks for exactly two iOS usage descriptions", () => { + it("asks for exactly four iOS usage descriptions", () => { expect(expectedPermissionSurface().iosUsageDescriptionKeys).toEqual([ + "NSMicrophoneUsageDescription", + "NSSpeechRecognitionUsageDescription", "NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription", ]); @@ -50,10 +52,11 @@ describe("the permitted surface is minimal", () => { expect(expectedPermissionSurface().iosBackgroundModes).toEqual(["location"]); }); - it("asks for twelve Android permissions and no more", () => { + it("asks for thirteen Android permissions and no more", () => { expect(expectedPermissionSurface().androidPermissions).toEqual([ "androidx.car.app.NAVIGATION_TEMPLATES", "androidx.car.app.ACCESS_SURFACE", + "android.permission.RECORD_AUDIO", "android.permission.INTERNET", "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", @@ -91,7 +94,7 @@ describe("the app config matches the permitted surface", () => { it("blocks the capabilities that would otherwise arrive by default", () => { for (const id of [ "com.google.android.c2dm.permission.RECEIVE", - "android.permission.RECORD_AUDIO", + "android.permission.FOREGROUND_SERVICE_MICROPHONE", "android.permission.CAMERA", "android.permission.QUERY_ALL_PACKAGES", ]) { diff --git a/apps/mobile/compliance/permissions.ts b/apps/mobile/compliance/permissions.ts index 150d9afa5..720f7d7f5 100644 --- a/apps/mobile/compliance/permissions.ts +++ b/apps/mobile/compliance/permissions.ts @@ -21,6 +21,17 @@ export interface PermissionEntry { } export const IOS_USAGE_DESCRIPTION_KEYS: PermissionEntry[] = [ + { + id: "NSMicrophoneUsageDescription", + practiceId: "voice-search-audio", + reason: "Linked speech module usage description; voice input remains disabled on iOS.", + }, + { + id: "NSSpeechRecognitionUsageDescription", + practiceId: "voice-search-audio", + reason: "Linked speech module usage description; voice input remains disabled on iOS.", + }, + { id: "NSLocationWhenInUseUsageDescription", practiceId: "precise-location-active-navigation", @@ -53,6 +64,11 @@ export const ANDROID_PERMISSIONS: PermissionEntry[] = [ practiceId: "background-location-active-navigation", reason: "Map-only rendering on the Android Auto surface.", }, + { + id: "android.permission.RECORD_AUDIO", + practiceId: "voice-search-audio", + reason: "On-device voice search initiated by the microphone button.", + }, { id: "android.permission.INTERNET", practiceId: "route-request-coordinates", @@ -112,11 +128,11 @@ export const ANDROID_PERMISSIONS: PermissionEntry[] = [ * Capabilities that must be absent, and why each would be a problem. * * Redundant with the allowlist by construction, and kept anyway: the message a - * failing check prints is the useful artefact, and "RECORD_AUDIO is present" + * failing check prints is the useful artefact, and "CAMERA is present" * says much less than the reason it must not be. */ export const FORBIDDEN_ANDROID_PERMISSIONS: Record = { - "android.permission.RECORD_AUDIO": "The shell has no microphone feature and declares none.", + "android.permission.FOREGROUND_SERVICE_MICROPHONE": "Voice input is foreground only.", "android.permission.CAMERA": "The shell has no camera feature.", "android.permission.READ_CONTACTS": "Never requested.", "android.permission.READ_CALENDAR": "Never requested.", @@ -142,7 +158,6 @@ export const FORBIDDEN_ANDROID_PERMISSIONS: Record = { }; export const FORBIDDEN_IOS_KEYS: Record = { - NSMicrophoneUsageDescription: "The shell has no microphone feature.", NSCameraUsageDescription: "The shell has no camera feature.", NSContactsUsageDescription: "Never requested.", NSCalendarsUsageDescription: "Never requested.", diff --git a/apps/mobile/compliance/store-answers.mts b/apps/mobile/compliance/store-answers.mts index 48ca7e397..4db3fb0e2 100644 --- a/apps/mobile/compliance/store-answers.mts +++ b/apps/mobile/compliance/store-answers.mts @@ -92,6 +92,7 @@ const REQUIRED_COVERAGE: { subject: string; practiceId: string }[] = [ { subject: "WebView cookies and cache", practiceId: "webview-storage" }, { subject: "map tile and imagery providers", practiceId: "map-and-media-providers" }, { subject: "store distribution", practiceId: "store-operational-data" }, + { subject: "on-device voice-search audio", practiceId: "voice-search-audio" }, ]; const byId = new Map(registry.practices.map((practice) => [practice.id, practice])); @@ -115,6 +116,26 @@ const PREAMBLE = ``; +function voiceAudioSection(): string { + const practice = byId.get("voice-search-audio") as Practice; + return `## On-device voice-search audio + +${practice.purpose} + +${practice.retention.note} + +User control: ${practice.userControl} + +Proposed collection: Apple ${practice.appleLabel.collected ? "Yes" : "No"}; Google ${practice.googleDataSafety.collected ? "Yes" : "No"}. +Proposed Google sharing: ${practice.googleDataSafety.shared ? "Yes" : "No"}. +Purpose: ${(practice.googleDataSafety.purposes ?? []).join(", ")}. + +${practice.notes} + +These are design classifications, pending candidate device/privacy qualification. +`; +} + function appleWorksheet(): string { const rows = collectedForApple .map( @@ -161,6 +182,7 @@ below remain pending review; omitted rows do not establish an exemption. | --- | --- | --- | --- | --- | ${rows} +${voiceAudioSection()} ## Tracking Proposed answer: No. Verify the exact binary's dependencies and privacy manifests, @@ -248,6 +270,7 @@ also be reviewed; their absence does not establish that data is not collected. | --- | --- | --- | --- | --- | --- | ${rows} +${voiceAudioSection()} ## Security practices to verify - Verify HTTPS for the binary, served web code and every configured provider path. diff --git a/apps/mobile/config/locales/de.json b/apps/mobile/config/locales/de.json index b83d2d151..ae8ec4281 100644 --- a/apps/mobile/config/locales/de.json +++ b/apps/mobile/config/locales/de.json @@ -1,4 +1,6 @@ { "NSLocationWhenInUseUsageDescription": "OpenMapX verwendet deinen Standort, um deine Position auf der Karte anzuzeigen und dich entlang einer von dir gestarteten Route zu führen.", - "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX verwendet deinen Standort im Hintergrund, damit eine von dir gestartete Navigation dich weiter führt, während der Bildschirm gesperrt ist oder die App nicht verwendet wird. Die Standorterfassung endet, sobald du die Navigation beendest." + "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX verwendet deinen Standort im Hintergrund, damit eine von dir gestartete Navigation dich weiter führt, während der Bildschirm gesperrt ist oder die App nicht verwendet wird. Die Standorterfassung endet, sobald du die Navigation beendest.", + "NSMicrophoneUsageDescription": "OpenMapX verwendet das Mikrofon für die Sprachsuche, wenn du auf die Mikrofontaste tippst.", + "NSSpeechRecognitionUsageDescription": "OpenMapX bindet die Spracherkennung für die Sprachsuche ein. Die Sprachsuche ist derzeit nur unter Android verfügbar." } diff --git a/apps/mobile/config/locales/en.json b/apps/mobile/config/locales/en.json index 8040169f1..e66a23490 100644 --- a/apps/mobile/config/locales/en.json +++ b/apps/mobile/config/locales/en.json @@ -1,4 +1,6 @@ { "NSLocationWhenInUseUsageDescription": "OpenMapX uses your location to show where you are on the map and to guide you along a route you start.", - "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX uses your location in the background so navigation you have started keeps guiding you while the screen is locked or the app is not in use. Tracking stops when you end navigation." + "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX uses your location in the background so navigation you have started keeps guiding you while the screen is locked or the app is not in use. Tracking stops when you end navigation.", + "NSMicrophoneUsageDescription": "OpenMapX uses the microphone for voice search when you tap the microphone button.", + "NSSpeechRecognitionUsageDescription": "OpenMapX links speech recognition for voice search. Voice search is currently available only on Android." } diff --git a/apps/mobile/config/nativeCopy.ts b/apps/mobile/config/nativeCopy.ts index 07f69564f..9ad08a83a 100644 --- a/apps/mobile/config/nativeCopy.ts +++ b/apps/mobile/config/nativeCopy.ts @@ -19,6 +19,8 @@ export const MOBILE_LOCALES = ["en", "de"] as const; export type MobileLocale = (typeof MOBILE_LOCALES)[number]; export interface IosUsageDescriptions { + NSMicrophoneUsageDescription: string; + NSSpeechRecognitionUsageDescription: string; NSLocationWhenInUseUsageDescription: string; NSLocationAlwaysAndWhenInUseUsageDescription: string; } @@ -30,12 +32,22 @@ export interface IosUsageDescriptions { */ export const IOS_USAGE_DESCRIPTIONS: Record = { en: { + NSMicrophoneUsageDescription: + "OpenMapX uses the microphone for voice search when you tap the microphone button.", + NSSpeechRecognitionUsageDescription: + "OpenMapX links speech recognition for voice search. Voice search is currently available only on Android.", + NSLocationWhenInUseUsageDescription: "OpenMapX uses your location to show where you are on the map and to guide you along a route you start.", NSLocationAlwaysAndWhenInUseUsageDescription: "OpenMapX uses your location in the background so navigation you have started keeps guiding you while the screen is locked or the app is not in use. Tracking stops when you end navigation.", }, de: { + NSMicrophoneUsageDescription: + "OpenMapX verwendet das Mikrofon für die Sprachsuche, wenn du auf die Mikrofontaste tippst.", + NSSpeechRecognitionUsageDescription: + "OpenMapX bindet die Spracherkennung für die Sprachsuche ein. Die Sprachsuche ist derzeit nur unter Android verfügbar.", + NSLocationWhenInUseUsageDescription: "OpenMapX verwendet deinen Standort, um deine Position auf der Karte anzuzeigen und dich entlang einer von dir gestarteten Route zu führen.", NSLocationAlwaysAndWhenInUseUsageDescription: diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 12c30a401..3f8c23617 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -33,6 +33,7 @@ "expo-keep-awake": "~57.0.2", "expo-location": "~57.0.20", "expo-notifications": "~57.0.21", + "expo-speech-recognition": "57.1.1", "expo-splash-screen": "~57.0.9", "expo-sqlite": "~57.0.3", "expo-system-ui": "~57.0.4", diff --git a/apps/mobile/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index 33cee47c5..67564775d 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -27,6 +27,7 @@ export const EXPO_LOCATION_SERVICE_NAMES = ["expo.modules.location.services.Loca export const REQUIRED_ANDROID_PERMISSIONS = [ "androidx.car.app.NAVIGATION_TEMPLATES", "androidx.car.app.ACCESS_SURFACE", + "android.permission.RECORD_AUDIO", "android.permission.INTERNET", "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", @@ -214,14 +215,19 @@ export interface AndroidManifestLike { * a duplicate `` node. */ export function applyAndroidPermissions(manifest: AndroidManifestLike): AndroidManifestLike { - const existing = manifest.manifest["uses-permission"] ?? []; + const existing = (manifest.manifest["uses-permission"] ?? []).filter( + (node) => + node.$["android:name"] !== "android.permission.FOREGROUND_SERVICE_MICROPHONE" || + node.$["tools:node"] === "remove", + ); const present = new Set(existing.map((node) => node.$["android:name"])); const additions = REQUIRED_ANDROID_PERMISSIONS.filter((name) => !present.has(name)).map( (name) => ({ $: { "android:name": name }, }), ); - if (additions.length === 0) return manifest; + if (additions.length === 0 && existing.length === manifest.manifest["uses-permission"]?.length) + return manifest; return { ...manifest, manifest: { ...manifest.manifest, "uses-permission": [...existing, ...additions] }, diff --git a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts index cc9e21d67..df413832a 100644 --- a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts +++ b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts @@ -432,3 +432,35 @@ describe("Android release signing", () => { expect(() => applyAndroidReleaseSigning("android {}\n")).toThrow(/signing/); }); }); + +it("strips a speculative microphone foreground service permission but keeps merge denial", () => { + const result = applyAndroidPermissions({ + manifest: { + $: {}, + "uses-permission": [ + { $: { "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE" } }, + { + $: { + "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE", + "tools:node": "remove", + }, + }, + ], + }, + }); + expect( + result.manifest["uses-permission"]?.filter( + (node) => node.$["android:name"] === "android.permission.FOREGROUND_SERVICE_MICROPHONE", + ), + ).toEqual([ + { + $: { + "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE", + "tools:node": "remove", + }, + }, + ]); + expect(result.manifest["uses-permission"]).toContainEqual({ + $: { "android:name": "android.permission.RECORD_AUDIO" }, + }); +}); diff --git a/apps/mobile/plugins/withOpenMapXNativeConfig.ts b/apps/mobile/plugins/withOpenMapXNativeConfig.ts index c88b129d2..b5fd825b6 100644 --- a/apps/mobile/plugins/withOpenMapXNativeConfig.ts +++ b/apps/mobile/plugins/withOpenMapXNativeConfig.ts @@ -104,6 +104,19 @@ export const withOpenMapXNativeConfig: ConfigPlugin = (config) => { ), input, ); + // Service intent visibility is sufficient for the on-device recognizer; + // the speech plugin's default Google-app package query is unnecessary. + for (const query of manifestConfig.modResults.manifest.queries ?? []) { + if ( + query.intent?.some((intent) => + intent.action?.some( + (action) => action.$["android:name"] === "android.speech.RecognitionService", + ), + ) + ) { + delete query.package; + } + } manifestConfig.modResults = updated as unknown as typeof manifestConfig.modResults; return manifestConfig; }); diff --git a/apps/mobile/scripts/assert-no-community-runtime.mts b/apps/mobile/scripts/assert-no-community-runtime.mts index e94e49ef7..e6291e07f 100644 --- a/apps/mobile/scripts/assert-no-community-runtime.mts +++ b/apps/mobile/scripts/assert-no-community-runtime.mts @@ -1,4 +1,5 @@ #!/usr/bin/env node + /** * Fails when the shell could execute code a reviewer never saw. * @@ -14,7 +15,9 @@ */ import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { hasWebViewCaptureDenial } from "./webViewCapturePolicy.ts"; const repoRoot = resolve(import.meta.dirname, "../../.."); @@ -144,6 +147,27 @@ if (app) { } } +const require = createRequire(import.meta.url); +const webViewSource = join( + dirname(require.resolve("react-native-webview/package.json")), + "android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java", +); +if (!hasWebViewCaptureDenial(readFileSync(webViewSource, "utf8"))) { + findings.push({ + file: webViewSource, + problem: "installed WebView capture-denial patch is missing", + }); +} + +const speechSource = join( + dirname(require.resolve("expo-speech-recognition/package.json")), + "android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt", +); +const speechCode = readFileSync(speechSource, "utf8"); +if (/log\([^\n]*(?:\$resultsList|\$nonEmptyStrings)/.test(speechCode)) { + findings.push({ file: speechSource, problem: "installed speech module logs transcripts" }); +} + if (findings.length > 0) { console.error("[assert-no-community-runtime] the store distribution boundary is not intact:\n"); for (const finding of findings) console.error(` ${finding.file}: ${finding.problem}`); diff --git a/apps/mobile/scripts/assert-release-permissions.mts b/apps/mobile/scripts/assert-release-permissions.mts index 41803e28d..4122978c7 100644 --- a/apps/mobile/scripts/assert-release-permissions.mts +++ b/apps/mobile/scripts/assert-release-permissions.mts @@ -5,7 +5,7 @@ * The app config states what the app should request; this reads what it actually * requests, after every config plugin and every transitive dependency has had * its say. Those are different things, and the gap between them is where an - * unwanted permission arrives — a library adds `RECORD_AUDIO` to its manifest, + * unwanted permission arrives — a library adds `CAMERA` to its manifest, * the merge pulls it in, and nothing in the committed source mentions it. * * Run after `expo prebuild`: diff --git a/apps/mobile/scripts/clean-prebuild-check.test.ts b/apps/mobile/scripts/clean-prebuild-check.test.ts index be821fdef..a5e0a5fae 100644 --- a/apps/mobile/scripts/clean-prebuild-check.test.ts +++ b/apps/mobile/scripts/clean-prebuild-check.test.ts @@ -10,7 +10,12 @@ function androidManifest(): AndroidManifestLike { "uses-permission": [ { $: { "android:name": "android.permission.ACCESS_FINE_LOCATION" } }, { $: { "android:name": "android.permission.INTERNET" } }, - { $: { "android:name": "android.permission.RECORD_AUDIO", "tools:node": "remove" } }, + { + $: { + "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE", + "tools:node": "remove", + }, + }, ], application: [ { diff --git a/apps/mobile/scripts/generatedNativeChecks.test.ts b/apps/mobile/scripts/generatedNativeChecks.test.ts index 5c362c466..7b888f1b0 100644 --- a/apps/mobile/scripts/generatedNativeChecks.test.ts +++ b/apps/mobile/scripts/generatedNativeChecks.test.ts @@ -33,9 +33,17 @@ function androidManifest(): AndroidManifestLike { return { manifest: { $: { "xmlns:android": "http://schemas.android.com/apk/res/android" }, + queries: [ + { intent: [{ action: [{ $: { "android:name": "android.speech.RecognitionService" } }] }] }, + ], "uses-permission": [ ...REQUIRED_ANDROID_PERMISSIONS.map((name) => ({ $: { "android:name": name } })), - { $: { "android:name": "android.permission.RECORD_AUDIO", "tools:node": "remove" } }, + { + $: { + "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE", + "tools:node": "remove", + }, + }, ], application: [ { @@ -62,6 +70,10 @@ function surface(): GeneratedNativeSurface { CFBundleShortVersionString: "$(MARKETING_VERSION)", }, infoPlist: { + NSMicrophoneUsageDescription: + "OpenMapX uses the microphone for voice search when you tap the microphone button.", + NSSpeechRecognitionUsageDescription: + "OpenMapX links speech recognition for voice search; disabled on iOS.", NSSupportsLiveActivities: true, UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: false, @@ -263,10 +275,10 @@ describe("Android policy", () => { expect( failuresAfter((value) => { value.androidManifest.manifest["uses-permission"]?.push({ - $: { "android:name": "android.permission.RECORD_AUDIO" }, + $: { "android:name": "android.permission.FOREGROUND_SERVICE_MICROPHONE" }, }); }), - ).toContainEqual(expect.stringContaining("RECORD_AUDIO")); + ).toContainEqual(expect.stringContaining("FOREGROUND_SERVICE_MICROPHONE")); }); it("rejects release cleartext traffic", () => { @@ -436,3 +448,11 @@ it("requires the generated ActivityKit support flag", () => { expect.stringContaining("NSSupportsLiveActivities"), ); }); + +it("requires speech service visibility", () => { + expect( + failuresAfter((value) => { + delete value.androidManifest.manifest.queries; + }), + ).toContain("Android speech RecognitionService visibility query is missing"); +}); diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index 8b89e42b7..a20221e8f 100644 --- a/apps/mobile/scripts/generatedNativeChecks.ts +++ b/apps/mobile/scripts/generatedNativeChecks.ts @@ -53,7 +53,7 @@ export interface GeneratedNativeSurface { /** Permissions that must never reach a generated manifest without removal. */ export const FORBIDDEN_ANDROID_PERMISSIONS = [ - "android.permission.RECORD_AUDIO", + "android.permission.FOREGROUND_SERVICE_MICROPHONE", "android.permission.CAMERA", "android.permission.READ_CONTACTS", "android.permission.READ_CALENDAR", @@ -171,6 +171,8 @@ function checkIos( } for (const key of [ + "NSMicrophoneUsageDescription", + "NSSpeechRecognitionUsageDescription", "NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription", ]) { @@ -234,6 +236,22 @@ function checkAndroid( fail: (message: string) => void, ): void { const manifest = generated.androidManifest.manifest; + const queries = manifest.queries as + | Array<{ + intent?: Array<{ action?: Array<{ $: Record }> }>; + }> + | undefined; + if ( + !queries?.some((query) => + query.intent?.some((intent) => + intent.action?.some( + (action) => action.$["android:name"] === "android.speech.RecognitionService", + ), + ), + ) + ) { + fail("Android speech RecognitionService visibility query is missing"); + } const permissionNodes = manifest["uses-permission"] ?? []; const counts = new Map(); diff --git a/apps/mobile/scripts/webViewCapturePolicy.test.ts b/apps/mobile/scripts/webViewCapturePolicy.test.ts new file mode 100644 index 000000000..2184e7b43 --- /dev/null +++ b/apps/mobile/scripts/webViewCapturePolicy.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { hasWebViewCaptureDenial } from "./webViewCapturePolicy"; + +const require = createRequire(import.meta.url); +const installed = readFileSync( + join( + dirname(require.resolve("react-native-webview/package.json")), + "android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java", + ), + "utf8", +); +it("installed WebView denies capture before any permission processing", () => + expect(hasWebViewCaptureDenial(installed)).toBe(true)); +it.each(["RESOURCE_AUDIO_CAPTURE", "RESOURCE_VIDEO_CAPTURE", "request.deny();", "return;"])( + "rejects a broken denial: %s", + (fragment) => { + const start = installed.indexOf("public void onPermissionRequest"); + expect( + hasWebViewCaptureDenial( + installed.slice(0, start) + installed.slice(start).replace(fragment, "BROKEN"), + ), + ).toBe(false); + }, +); + +it("installed recognizer never logs transcript results", () => { + const speech = readFileSync( + join( + dirname(require.resolve("expo-speech-recognition/package.json")), + "android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt", + ), + "utf8", + ); + expect(speech).not.toMatch(/log\([^\n]*(?:\$resultsList|\$nonEmptyStrings)/); +}); diff --git a/apps/mobile/scripts/webViewCapturePolicy.ts b/apps/mobile/scripts/webViewCapturePolicy.ts new file mode 100644 index 000000000..9cc93039c --- /dev/null +++ b/apps/mobile/scripts/webViewCapturePolicy.ts @@ -0,0 +1,14 @@ +/** Verify the installed Java boundary, rather than merely the patch declaration. */ +export function hasWebViewCaptureDenial(source: string): boolean { + const method = source.split( + "public void onPermissionRequest(final PermissionRequest request) {", + )[1]; + if (!method) return false; + const code = method + .replace(/\/\/[^\n]*/g, "") + .replace(/\s+/g, " ") + .trim(); + return code.startsWith( + "for (String resource : request.getResources()) { if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource) || PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) { request.deny(); return; } }", + ); +} diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 32fa60869..1011abca1 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -1,9 +1,15 @@ +jest.mock("expo-speech-recognition", () => ({ + ExpoSpeechRecognitionModule: { supportsOnDeviceRecognition: jest.fn(() => false) }, +})); + import { MOBILE_PROTOCOL_MAX } from "@openmapx/core/navigation"; import { act, fireEvent, render } from "@testing-library/react-native"; import { Alert, AppState, BackHandler, Linking } from "react-native"; +import { z } from "zod"; import { App } from "./App"; import { resetRuntimeConfigCache } from "./config/runtimeConfig"; import { groundSessionFixture } from "./storage/testing/sessionFixture"; +import { VoiceInputController } from "./voice/VoiceInputController"; /** Props of the most recently rendered WebView, captured by the mock below. */ let mockWebViewProps: Record = {}; @@ -421,13 +427,64 @@ describe("App bridge channel", () => { await hello(); const reply = decodedMessages().find((message) => message.type === "native.hello"); - expect(reply?.payload?.capabilities).toMatchObject(NAVIGATION_CAPABILITIES); + // origin/main capability fixture: deployed web rejects every unknown key. + const oldCapabilities = z + .object({ + groundNavigation: z.boolean(), + transitNavigation: z.boolean(), + transitActions: z.boolean().optional(), + backgroundLocation: z.boolean(), + localNotifications: z.boolean(), + speech: z.boolean(), + }) + .strict(); + expect(oldCapabilities.parse(reply?.payload?.capabilities)).toEqual(NAVIGATION_CAPABILITIES); expect(mockCreateForegroundNavigation).toHaveBeenCalledTimes(1); expect(mockCreateForegroundNavigation.mock.calls[0]?.[0]).toMatchObject({ apiOrigin: "https://openmapx.com", }); }); + it.each([true, false])( + "answers voice probes with availability %s outside navigation", + async (available) => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + jest.spyOn(VoiceInputController.prototype, "isAvailable").mockReturnValue(available); + const nonce = await hello(); + mockNavigation.coordinator.dispatch.mockImplementation(() => new Promise(() => {})); + await post(nonce, { messageId: "probe-1", type: "voice.probe", payload: {} }); + expect(decodedMessages()).toContainEqual( + expect.objectContaining({ + type: "voice.availability", + payload: { forMessageId: "probe-1", available }, + }), + ); + expect(mockNavigation.coordinator.dispatch).not.toHaveBeenCalled(); + }, + ); + + it("dispatches voice commands outside navigation serialization", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + const start = jest.spyOn(VoiceInputController.prototype, "start").mockResolvedValue(); + const stop = jest.spyOn(VoiceInputController.prototype, "stop").mockImplementation(() => {}); + const cancel = jest + .spyOn(VoiceInputController.prototype, "cancel") + .mockImplementation(() => {}); + const nonce = await hello(); + mockNavigation.coordinator.dispatch.mockImplementation(() => new Promise(() => {})); + await post(nonce, { + messageId: "voice-1", + type: "voice.start", + payload: { requestId: "r", locale: "en-US" }, + }); + await post(nonce, { messageId: "voice-2", type: "voice.stop", payload: { requestId: "r" } }); + await post(nonce, { messageId: "voice-3", type: "voice.cancel", payload: { requestId: "r" } }); + expect(start).toHaveBeenCalledWith("r", "en-US"); + expect(stop).toHaveBeenCalledWith("r"); + expect(cancel).toHaveBeenCalledWith("r"); + expect(mockNavigation.coordinator.dispatch).not.toHaveBeenCalled(); + }); + it("hands every page command to the navigation coordinator", async () => { mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); const nonce = await hello(); @@ -732,3 +789,24 @@ it("confirms a warm surface End without waiting for the web bridge", async () => expect(alert.mock.calls.at(-1)?.[1]).toContain("End this"); expect(mockNavigation.coordinator.stopFromSurface).not.toHaveBeenCalled(); }); +it("cancels voice on document replacement, load failure, visibility and teardown", async () => { + const cancel = jest.spyOn(VoiceInputController.prototype, "cancel").mockImplementation(() => {}); + let change: ((status: "active" | "background" | "inactive") => void) | undefined; + jest.spyOn(AppState, "addEventListener").mockImplementation((type, handler) => { + if (type === "change") change = handler; + return { remove: jest.fn() }; + }); + const view = await renderApp(); + await act(async () => change?.("active")); + cancel.mockClear(); + await act(async () => (mockWebViewProps.onLoadStart as () => void)()); + expect(cancel).toHaveBeenCalledTimes(1); + await act(async () => (mockWebViewProps.onError as () => void)()); + expect(cancel).toHaveBeenCalledTimes(2); + await act(async () => change?.("inactive")); + expect(cancel).toHaveBeenCalledTimes(3); + await act(async () => change?.("background")); + expect(cancel).toHaveBeenCalledTimes(4); + await view.unmount(); + expect(cancel).toHaveBeenCalledTimes(5); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 01264ea7b..37c8b66ed 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,5 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; import { type ReactElement, useCallback, @@ -8,8 +9,9 @@ import { useRef, useState, } from "react"; -import { Alert, Linking, StatusBar, StyleSheet, View } from "react-native"; +import { Alert, Linking, Platform, StatusBar, StyleSheet, View } from "react-native"; import { WebView } from "react-native-webview"; +import { getNavigationAudio } from "./audio/navigationAudioModule"; import { createShellBridge, type ShellBridge } from "./bridge/createShellBridge"; import { buildChannelBootstrapScript } from "./bridge/outboundScript"; import { getRuntimeConfig, type MobileRuntimeConfig } from "./config/runtimeConfig"; @@ -36,6 +38,7 @@ import { deviceMobileLocale } from "./shell/shellCopy"; import { INITIAL_SHELL, shellReducer } from "./shell/shellReducer"; import { answerCommand, createShellServices } from "./shell/shellServices"; import { useHardwareBack } from "./shell/useHardwareBack"; +import { VoiceInputController } from "./voice/VoiceInputController"; /** * The entire visible product is the web UI inside one WebView pointed at the @@ -114,15 +117,45 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement navigatingRef.current = navigating; dispatchShell({ type: navigating ? "session-started" : "session-ended" }); }, []); - const { shell, navigation } = useMemo(() => { + const { shell, navigation, voice } = useMemo(() => { let navigation: Promise = Promise.resolve(null); + const voice = new VoiceInputController({ + module: ExpoSpeechRecognitionModule, + platform: Platform.OS, + api: Number(Platform.Version), + audio: getNavigationAudio(), + active: () => visibilityRef.current === "active", + channel: () => + composed.registry.handshake() ? (composed.registry.current()?.nonce ?? null) : null, + emit: (event) => composed.bridge.send("voice.event", event), + }); const composed: ShellBridge = createShellBridge({ webOrigin: config.webOrigin, inject: (script) => webViewRef.current?.injectJavaScript(script), - // Every command goes through the navigation coordinator, which also + // Navigation commands go through the coordinator, which also // answers the one-shot location, settings and sign-in requests. A build // without it (the feasibility probe) answers only those. dispatch: async (message) => { + if (message.type === "voice.probe") { + composed.bridge.send( + "voice.availability", + { available: voice.isAvailable() }, + { forMessageId: message.messageId }, + ); + return; + } + if (message.type === "voice.start") { + void voice.start(message.payload.requestId, message.payload.locale); + return; + } + if (message.type === "voice.stop") { + voice.stop(message.payload.requestId); + return; + } + if (message.type === "voice.cancel") { + voice.cancel(message.payload.requestId); + return; + } const composedNavigation = await navigation; if (composedNavigation) { await composedNavigation.coordinator.dispatch(message); @@ -147,8 +180,11 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement onNavigatingChanged: setNavigating, }).catch(() => null); } - return { shell: composed, navigation }; + return { shell: composed, navigation, voice }; }, [config, locale, prompter, services, setNavigating]); + useEffect(() => { + if (visibility !== "active") voice.suspend(); + }, [visibility, voice]); const deliverLink = useCallback( (intent: DeepLinkIntent) => { shell.bridge.send("deep-link.open", intent); @@ -191,9 +227,10 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement const incomingLinks = useIncomingLinks(config, deliverLink, requestNativeEnd); useEffect( () => () => { + voice.cancel(); void navigation.then((composed) => composed?.dispose()); }, - [navigation], + [navigation, voice], ); const recovery = useMemo( () => @@ -224,14 +261,15 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement if (event?.nativeEvent?.loading === false) return; // A real top-level navigation or reload. // - // A reload resets the bridge and nothing else: the native session and the - // location driver are unaffected, because the page is not their authority. + // Navigation and its location driver survive a reload. Voice input is + // owned by this document and must be cancelled before channel replacement. + voice.cancel(); incomingLinks.documentStarted(); shell.registry.adoptDocument(publishedNonce, Date.now()); shell.bridge.discardQueue(); dispatchShell({ type: "document-load-started" }); }, - [publishedNonce, shell, incomingLinks], + [publishedNonce, shell, incomingLinks, voice], ); const handleLoadEnd = useCallback(() => { @@ -244,11 +282,12 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement }, [shell]); const handleLoadFailure = useCallback(() => { + voice.cancel(); dispatchShell({ type: "document-load-failed", offline: connectivity.displayed === "offline" }); incomingLinks.documentStarted(); shell.registry.invalidate(); shell.bridge.discardQueue(); - }, [shell, connectivity.displayed, incomingLinks]); + }, [shell, connectivity.displayed, incomingLinks, voice]); const handleMessage = useCallback( (event: { nativeEvent: { url?: string; data?: string } }) => { @@ -378,9 +417,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement allowFileAccessFromFileURLs={false} allowUniversalAccessFromFileURLs={false} allowsInlineMediaPlayback={false} - // The page never gets camera or microphone. The shell declares neither - // permission, and a store build that prompts for a capability it never - // declared is a rejection rather than a feature. + // Native voice input owns the microphone; WebView capture remains denied. mediaCapturePermissionGrantType="deny" // Geolocation is native's, exclusively. A page that could ask the // WebView for it would be a second location producer racing the one diff --git a/apps/mobile/src/audio/navigationAudioModule.ts b/apps/mobile/src/audio/navigationAudioModule.ts index 4cc74e7dc..d24fbabe8 100644 --- a/apps/mobile/src/audio/navigationAudioModule.ts +++ b/apps/mobile/src/audio/navigationAudioModule.ts @@ -1,4 +1,5 @@ import { requireOptionalNativeModule } from "expo"; +import { createAudioArbiter, type VoiceAudioLease } from "../voice/audioArbiter"; import { createNavigationAudio, type NativeNavigationAudio, @@ -24,11 +25,13 @@ const UNAVAILABLE: NativeNavigationAudio = { }), }; -let cached: NavigationAudio | null = null; +let cached: (NavigationAudio & VoiceAudioLease) | null = null; -export function getNavigationAudio(): NavigationAudio { - cached ??= createNavigationAudio( - requireOptionalNativeModule("OpenMapXNavigationAudio") ?? UNAVAILABLE, +export function getNavigationAudio(): NavigationAudio & VoiceAudioLease { + cached ??= createAudioArbiter( + createNavigationAudio( + requireOptionalNativeModule("OpenMapXNavigationAudio") ?? UNAVAILABLE, + ), ); return cached; } diff --git a/apps/mobile/src/bridge/nativeBridge.test.ts b/apps/mobile/src/bridge/nativeBridge.test.ts index 54a7eb172..31c6877f3 100644 --- a/apps/mobile/src/bridge/nativeBridge.test.ts +++ b/apps/mobile/src/bridge/nativeBridge.test.ts @@ -573,3 +573,17 @@ describe("NativeBridge.send", () => { expect(bridge.queuedCount).toBe(0); }); }); + +it("never queues or replays voice events before a document handshake", async () => { + const f = harness(); + f.registry.beginDocumentLoad(NOW); + expect(f.bridge.send("voice.event", { requestId: "r", state: "listening" })).toBe("dropped"); + expect(f.bridge.queuedCount).toBe(0); + const next = f.registry.beginDocumentLoad(NOW); + await f.bridge.receive({ url: `${WEB_ORIGIN}/`, data: hello(next.nonce) }); + expect(f.injected.join("")).not.toContain("voice.event"); + expect(f.bridge.send("voice.event", { requestId: "s", state: "listening" })).toBe("sent"); + f.registry.invalidate(); + expect(f.bridge.send("voice.event", { requestId: "s", state: "ended" })).toBe("dropped"); + expect(f.bridge.queuedCount).toBe(0); +}); diff --git a/apps/mobile/src/bridge/nativeBridge.ts b/apps/mobile/src/bridge/nativeBridge.ts index eb3f92128..b78cf8c7a 100644 --- a/apps/mobile/src/bridge/nativeBridge.ts +++ b/apps/mobile/src/bridge/nativeBridge.ts @@ -260,6 +260,7 @@ export class NativeBridge { const validated = nativeToWebSchema.safeParse(candidate); if (!validated.success) return "invalid"; + if (draft.type === "voice.event" && !handshake) return "dropped"; if (!options.immediate && !handshake) return this.enqueue(validated.data); this.deps.inject(buildOutboundScript(validated.data)); diff --git a/apps/mobile/src/navigation/NavigationCoordinator.ts b/apps/mobile/src/navigation/NavigationCoordinator.ts index 9a6928bab..2051001d6 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.ts @@ -252,6 +252,12 @@ export class NavigationCoordinator { switch (command.type) { case "destinations.update": return null; + case "voice.probe": + case "voice.start": + case "voice.stop": + case "voice.cancel": + // Voice commands are intercepted by the shell, outside this serial queue. + return null; case "web.hello": // Answered by the bridge during the handshake; nothing to coordinate. return null; diff --git a/apps/mobile/src/voice/VoiceInputController.test.ts b/apps/mobile/src/voice/VoiceInputController.test.ts new file mode 100644 index 000000000..ecec1a316 --- /dev/null +++ b/apps/mobile/src/voice/VoiceInputController.test.ts @@ -0,0 +1,328 @@ +import { mapVoiceError, selectInstalledLocale, VoiceInputController } from "./VoiceInputController"; + +function fixture(platform = "android", api = 33, supported = true) { + const handlers = new Map void>>(); + const fire = (name: string, event: unknown = {}) => { + for (const handler of [...(handlers.get(name) ?? [])]) handler(event as never); + }; + const module = { + supportsOnDeviceRecognition: jest.fn(() => supported), + getMicrophonePermissionsAsync: jest.fn(async () => ({ granted: true, canAskAgain: true })), + requestMicrophonePermissionsAsync: jest.fn(async () => ({ granted: true, canAskAgain: true })), + getSupportedLocales: jest.fn(async () => ({ + installedLocales: ["de-DE", "en-US"], + locales: ["fr-FR"], + })), + androidTriggerOfflineModelDownload: jest.fn(async () => ({})), + start: jest.fn(), + stop: jest.fn(), + abort: jest.fn(), + addListener: jest.fn((name: string, listener: (event: never) => void) => { + const set = handlers.get(name) ?? new Set(); + handlers.set(name, set); + set.add(listener); + return { remove: () => set.delete(listener) }; + }), + }; + const emit = jest.fn(); + const audio = { acquire: jest.fn(async () => {}), release: jest.fn() }; + let channel: string | null = "document-a"; + let active = true; + const controller = new VoiceInputController({ + module: module as unknown as ConstructorParameters[0]["module"], + platform, + api, + audio, + emit, + channel: () => channel, + active: () => active, + downloads: new Set(), + }); + return { + controller, + module, + emit, + audio, + fire, + setChannel: (value: string | null) => { + channel = value; + }, + background: () => { + active = false; + controller.cancel(); + }, + setActive: (value: boolean) => { + active = value; + }, + }; +} + +describe("native voice input", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + it.each([ + ["ios", 35, true], + ["android", 32, true], + ["android", 35, false], + ])("fails closed on %s API %s supported %s", (os, api, supported) => { + expect(fixture(os, api, supported).controller.isAvailable()).toBe(false); + }); + it("uses only installed locales, exact first then primary language", () => { + expect(selectInstalledLocale("en-US", ["en-GB", "en-US"])).toBe("en-US"); + expect(selectInstalledLocale("en-AU", ["de-DE", "en-GB"])).toBe("en-GB"); + expect(selectInstalledLocale("fr-FR", ["en-US"])).toBeUndefined(); + }); + it("starts on-device without persistence or volume events and emits bounded results", async () => { + const f = fixture(); + await f.controller.start("r1", "de-DE"); + expect(f.module.requestMicrophonePermissionsAsync).not.toHaveBeenCalled(); + expect(f.module.start).toHaveBeenCalledWith({ + lang: "de-DE", + requiresOnDeviceRecognition: true, + interimResults: true, + continuous: false, + maxAlternatives: 1, + addsPunctuation: false, + recordingOptions: { persist: false }, + }); + f.fire("start"); + f.fire("result", { isFinal: false, results: [{ transcript: "Berlin" }] }); + f.fire("result", { isFinal: true, results: [{ transcript: "x".repeat(600) }] }); + f.fire("end"); + expect(f.emit.mock.calls.map(([e]) => e.state)).toEqual([ + "listening", + "partial", + "final", + "ended", + ]); + expect(f.emit.mock.calls[2][0].text).toHaveLength(512); + expect(f.audio.release).toHaveBeenCalledTimes(1); + }); + it.each([true, false])("denial canAskAgain=%s", async (canAskAgain) => { + const f = fixture(); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain }); + f.module.requestMicrophonePermissionsAsync.mockResolvedValue({ + granted: false, + canAskAgain: false, + }); + await f.controller.start("r", "en-US"); + expect(f.module.requestMicrophonePermissionsAsync).toHaveBeenCalledTimes(canAskAgain ? 1 : 0); + expect(f.emit).toHaveBeenCalledWith({ requestId: "r", state: "error", error: "not-allowed" }); + expect(f.module.start).not.toHaveBeenCalled(); + }); + it("requests first-tap permission then starts", async () => { + const f = fixture(); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain: true }); + await f.controller.start("r", "en-US"); + expect(f.module.start).toHaveBeenCalledTimes(1); + }); + it("downloads missing model once and rechecks installed models every start", async () => { + const f = fixture(); + await f.controller.start("r", "fr-FR"); + await f.controller.start("s", "fr-FR"); + expect(f.module.androidTriggerOfflineModelDownload).toHaveBeenCalledTimes(1); + expect(f.module.getSupportedLocales).toHaveBeenCalledTimes(2); + expect(f.module.start).not.toHaveBeenCalled(); + expect(f.emit).toHaveBeenLastCalledWith({ + requestId: "s", + state: "error", + error: "language-not-supported", + }); + }); + it("stop preserves final results, then ends quietly at 3 seconds without end", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.controller.stop("r"); + f.fire("result", { isFinal: true, results: [{ transcript: "Berlin" }] }); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "r", state: "final", text: "Berlin" }); + jest.advanceTimersByTime(3000); + expect(f.module.abort).toHaveBeenCalledTimes(1); + f.fire("result", { isFinal: true, results: [{ transcript: "late" }] }); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "r", state: "ended" }); + expect(f.emit.mock.calls.some(([event]) => event.state === "error")).toBe(false); + }); + it("releases guidance when the recogniser never ends after abort, but keeps it unconfirmed", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.controller.cancel("r"); + expect(f.audio.release).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(3000); + expect(f.audio.release).toHaveBeenCalledTimes(1); + // A new start asks the stuck recogniser again and must not attach + // listeners until it confirms; the old recogniser's output arrives first. + f.emit.mockClear(); + const next = f.controller.start("s", "en-US"); + await jest.advanceTimersByTimeAsync(10); + expect(f.module.abort).toHaveBeenCalledTimes(2); + f.fire("result", { isFinal: true, results: [{ transcript: "late" }] }); + expect(f.module.start).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(3000); + await next; + expect(f.module.start).toHaveBeenCalledTimes(1); + expect(f.emit.mock.calls.some(([event]) => event.text === "late")).toBe(false); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "s", state: "error", error: "busy" }); + }); + it("starts again once the stuck recogniser finally confirms its end", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.controller.cancel("r"); + await jest.advanceTimersByTimeAsync(3000); + const next = f.controller.start("s", "en-US"); + await jest.advanceTimersByTimeAsync(10); + f.fire("end"); + await jest.advanceTimersByTimeAsync(10); + await next; + expect(f.module.start).toHaveBeenCalledTimes(2); + f.fire("result", { isFinal: true, results: [{ transcript: "Aachen" }] }); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "s", state: "final", text: "Aachen" }); + }); + it("survives the permission prompt pausing the app and starts once it resumes", async () => { + const f = fixture(); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain: true }); + f.module.requestMicrophonePermissionsAsync.mockImplementation(async () => { + // Android's prompt is another activity: the app reads as backgrounded, + // and the lifecycle hook suspends voice input. + f.setActive(false); + f.controller.suspend(); + return { granted: true, canAskAgain: true }; + }); + const start = f.controller.start("r", "en-US"); + // The permission result arrives before the activity resumes. + await jest.advanceTimersByTimeAsync(100); + expect(f.module.start).not.toHaveBeenCalled(); + f.setActive(true); + await jest.advanceTimersByTimeAsync(100); + await start; + expect(f.module.start).toHaveBeenCalledTimes(1); + }); + it("reports a refusal from the prompt to the page", async () => { + const f = fixture(); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain: true }); + f.module.requestMicrophonePermissionsAsync.mockImplementation(async () => { + f.setActive(false); + f.controller.suspend(); + f.setActive(true); + return { granted: false, canAskAgain: true }; + }); + await f.controller.start("r", "en-US"); + expect(f.emit).toHaveBeenLastCalledWith({ + requestId: "r", + state: "error", + error: "not-allowed", + }); + }); + it("ends quietly without recording when the user stays away after the prompt", async () => { + const f = fixture(); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain: true }); + f.module.requestMicrophonePermissionsAsync.mockImplementation(async () => { + f.setActive(false); + return { granted: true, canAskAgain: true }; + }); + const start = f.controller.start("r", "en-US"); + await jest.advanceTimersByTimeAsync(2500); + await start; + expect(f.module.start).not.toHaveBeenCalled(); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "r", state: "ended" }); + }); + it("tells the page listening ended when the app goes to the background", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.setActive(false); + f.controller.suspend(); + expect(f.module.abort).toHaveBeenCalledTimes(1); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "r", state: "ended" }); + // Transcripts never reach a backgrounded page. + f.fire("result", { isFinal: true, results: [{ transcript: "late" }] }); + expect(f.emit.mock.calls.some(([event]) => event.text === "late")).toBe(false); + }); + it("releases guidance when a native error is never followed by end", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.fire("error", { error: "audio-capture" }); + jest.advanceTimersByTime(3000); + expect(f.audio.release).toHaveBeenCalledTimes(1); + }); + it("caps utterances at 15 seconds", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + jest.advanceTimersByTime(15000); + expect(f.module.stop).toHaveBeenCalledTimes(1); + f.fire("end"); + jest.advanceTimersByTime(3000); + expect(f.module.abort).not.toHaveBeenCalled(); + }); + it.each(["cancel", "background", "document"])( + "discards late results after %s", + async (reason) => { + const f = fixture(); + await f.controller.start("r", "en-US"); + if (reason === "background") f.background(); + else if (reason === "document") f.setChannel("document-b"); + else f.controller.cancel("r"); + f.emit.mockClear(); + f.fire("result", { isFinal: true, results: [{ transcript: "late" }] }); + f.fire("end"); + expect(f.emit).not.toHaveBeenCalled(); + }, + ); + it("new start waits for abort end and cannot inherit a late result", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + const next = f.controller.start("s", "de-DE"); + f.fire("result", { isFinal: true, results: [{ transcript: "late" }] }); + expect(f.module.start).toHaveBeenCalledTimes(1); + f.fire("end"); + await next; + expect(f.module.start).toHaveBeenCalledTimes(2); + expect(f.emit.mock.calls.some(([e]) => e.text === "late")).toBe(false); + }); + it("stuck abort fails closed instead of sharing native events", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + const next = f.controller.start("s", "en-US"); + jest.advanceTimersByTime(3000); + await next; + expect(f.module.start).toHaveBeenCalledTimes(1); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "s", state: "error", error: "busy" }); + }); + it.each([ + ["network", "generic"], + ["service-not-allowed", "unavailable"], + ["speech-timeout", "no-speech"], + ["audio-capture", "audio-capture"], + ])("maps %s to %s", (input, output) => expect(mapVoiceError(input)).toBe(output)); + it("nomatch is terminal and waits for native teardown", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.fire("nomatch"); + expect(f.emit).toHaveBeenLastCalledWith({ requestId: "r", state: "error", error: "no-speech" }); + f.fire("end"); + expect(f.audio.release).toHaveBeenCalled(); + }); + it("cancellation during a permission request cannot start recognition later", async () => { + const f = fixture(); + let resolve!: (value: { granted: boolean; canAskAgain: boolean }) => void; + f.module.getMicrophonePermissionsAsync.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const start = f.controller.start("r", "en-US"); + f.controller.cancel("r"); + resolve({ granted: true, canAskAgain: true }); + await start; + expect(f.module.start).not.toHaveBeenCalled(); + }); + it("native error owns teardown; no duplicate abort/end enters a new session", async () => { + const f = fixture(); + await f.controller.start("r", "en-US"); + f.fire("nomatch"); + f.fire("error", { error: "no-speech" }); + f.controller.cancel("r"); + expect(f.module.abort).not.toHaveBeenCalled(); + expect(f.emit.mock.calls.filter(([event]) => event.state === "error")).toHaveLength(1); + f.fire("end"); + expect(f.audio.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/voice/VoiceInputController.ts b/apps/mobile/src/voice/VoiceInputController.ts new file mode 100644 index 000000000..0c778f2c0 --- /dev/null +++ b/apps/mobile/src/voice/VoiceInputController.ts @@ -0,0 +1,366 @@ +import type { NativeToWebMessage } from "@openmapx/core/navigation"; +import type { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; +import type { VoiceAudioLease } from "./audioArbiter"; + +type SpeechModule = Pick< + typeof ExpoSpeechRecognitionModule, + | "start" + | "stop" + | "abort" + | "addListener" + | "supportsOnDeviceRecognition" + | "getMicrophonePermissionsAsync" + | "requestMicrophonePermissionsAsync" + | "getSupportedLocales" + | "androidTriggerOfflineModelDownload" +>; +type VoiceEvent = Extract["payload"]; +type VoiceError = NonNullable; +export interface VoiceClock { + setTimeout: typeof setTimeout; + clearTimeout: typeof clearTimeout; +} +interface Session { + id: string; + channel: string; + cancelled: boolean; + started: boolean; + stopping: boolean; + /** Waiting on the OS permission prompt, which pauses the app's activity. */ + prompting: boolean; + /** The recogniser reported an error, which ends its recognition. */ + nativeStopped: boolean; + timers: ReturnType[]; + remove: (() => void)[]; + drained: Promise; + drain: () => void; +} + +const downloads = new Set(); +const SETTLE_MS = 3000; +const RESUME_MS = 2000; +const RESUME_POLL_MS = 50; +export function selectInstalledLocale(requested: string, installed: string[]): string | undefined { + return ( + installed.find((tag) => tag.toLowerCase() === requested.toLowerCase()) ?? + installed.find( + (tag) => tag.split("-")[0].toLowerCase() === requested.split("-")[0].toLowerCase(), + ) + ); +} +export function mapVoiceError(error: string): VoiceError { + if ( + [ + "not-allowed", + "language-not-supported", + "no-speech", + "audio-capture", + "busy", + "aborted", + ].includes(error) + ) + return error as VoiceError; + if (error === "speech-timeout") return "no-speech"; + if (error === "service-not-allowed") return "unavailable"; + return "generic"; +} + +export class VoiceInputController { + private session: Session | null = null; + private lease: Session | null = null; + /** A finished session whose recogniser has not yet confirmed it stopped. */ + private unconfirmed: Session | null = null; + constructor( + private readonly deps: { + module: SpeechModule; + platform: string; + api: number; + audio: VoiceAudioLease; + channel: () => string | null; + active: () => boolean; + emit: (event: VoiceEvent) => void; + clock?: VoiceClock; + downloads?: Set; + }, + ) {} + isAvailable(): boolean { + try { + return ( + this.deps.platform === "android" && + this.deps.api >= 33 && + this.deps.module.supportsOnDeviceRecognition() + ); + } catch { + return false; + } + } + private get clock(): VoiceClock { + return this.deps.clock ?? { setTimeout, clearTimeout }; + } + private current(s: Session): boolean { + return ( + this.session === s && !s.cancelled && this.deps.channel() === s.channel && this.deps.active() + ); + } + private emit(s: Session, event: Omit): void { + // Transcripts only reach a visible page. A terminal state must reach the + // page that started the session even while the app is in the background, + // or its button would keep showing "listening" after cancellation. + const terminal = event.state === "ended" || event.state === "error"; + const owned = this.session === s && !s.cancelled && this.deps.channel() === s.channel; + if (terminal ? owned : this.current(s)) this.deps.emit({ requestId: s.id, ...event }); + } + /** + * Android shows the permission prompt as another activity, so the app reads + * as backgrounded until the prompt closes and the activity resumes, which + * happens after the permission result arrives. Wait (bounded) for that. + */ + private async resumed(s: Session): Promise { + for (let waited = 0; waited < RESUME_MS; waited += RESUME_POLL_MS) { + if (this.session !== s || s.cancelled) return false; + if (this.deps.active()) return true; + await new Promise((resolve) => this.clock.setTimeout(resolve, RESUME_POLL_MS)); + } + return this.current(s); + } + /** + * Ends a session's ownership. `confirmed` means the recogniser itself said it + * stopped (or never started), which is the only thing that makes it safe to + * attach a later session's listeners: native events carry no request id. + */ + private finish(s: Session, confirmed = true): void { + for (const timer of s.timers) this.clock.clearTimeout(timer); + for (const remove of s.remove) remove(); + s.remove = []; + if (this.session === s) this.session = null; + if (this.lease === s) { + this.lease = null; + this.deps.audio.release(); + } + if (confirmed || s.nativeStopped) { + s.drain(); + return; + } + // Wait for the recogniser's own end or error before any later session. + this.unconfirmed = s; + const on: SpeechModule["addListener"] = this.deps.module.addListener.bind(this.deps.module); + const watchers: { remove: () => void }[] = []; + const confirm = () => { + for (const watcher of watchers) watcher.remove(); + if (this.unconfirmed === s) this.unconfirmed = null; + s.drain(); + }; + watchers.push(on("end", confirm), on("error", confirm)); + } + /** + * A started session normally finishes on the recogniser's `end`. Some + * interruptions never deliver it. After this deadline the session releases + * guidance and its listeners anyway, so guidance is never held for long, but + * it stays unconfirmed: the next start aborts again and waits for the + * recogniser to confirm, answering "busy" rather than risk receiving the old + * recogniser's output under a new request id. + */ + private settle(s: Session): void { + s.timers.push(this.clock.setTimeout(() => this.finish(s, false), SETTLE_MS)); + } + private abortQuietly(s: Session): void { + this.emit(s, { state: "ended" }); + s.cancelled = true; + for (const timer of s.timers) this.clock.clearTimeout(timer); + s.timers = []; + if (!s.started) { + this.finish(s); + return; + } + try { + this.deps.module.abort(); + } catch { + /* The settle deadline still releases the session. */ + } + this.settle(s); + } + private fail(s: Session, error: VoiceError, abort = true): void { + if (s.cancelled) return; + this.emit(s, { state: "error", error }); + s.cancelled = true; + if (!s.started) { + this.finish(s); + return; + } + if (abort) { + try { + this.deps.module.abort(); + } catch { + /* The settle deadline still releases the session. */ + } + } + this.settle(s); + } + async start(id: string, locale: string): Promise { + const previous = this.session; + this.cancel(); + const channel = this.deps.channel(); + if (!channel || !this.deps.active()) return; + let drain = () => {}; + const drained = new Promise((resolve) => { + drain = resolve; + }); + const s: Session = { + id, + channel, + cancelled: false, + started: false, + stopping: false, + prompting: false, + nativeStopped: false, + timers: [], + remove: [], + drained, + drain, + }; + this.session = s; + try { + // Native events have no request id. Never attach a new listener until the + // previous recognizer has acknowledged end; fail closed on a stuck abort. + const pending = this.unconfirmed ?? (previous?.started ? previous : null); + if (pending) { + if (pending === this.unconfirmed) { + try { + // Ask the stuck recogniser again; its end or error confirms it. + this.deps.module.abort(); + } catch { + /* Still unconfirmed: the wait below answers "busy". */ + } + } + const ready = await new Promise((resolve) => { + const timer = this.clock.setTimeout(() => resolve(false), 3000); + void pending.drained.then(() => { + this.clock.clearTimeout(timer); + resolve(true); + }); + }); + if (!this.current(s)) return; + if (!ready) { + this.fail(s, "busy"); + return; + } + } + if (this.lease) { + this.fail(s, "busy"); + return; + } + if (!this.isAvailable()) { + this.fail(s, "unavailable"); + return; + } + let permission = await this.deps.module.getMicrophonePermissionsAsync(); + if (!this.current(s)) return; + if (!permission.granted && permission.canAskAgain) { + s.prompting = true; + try { + permission = await this.deps.module.requestMicrophonePermissionsAsync(); + } finally { + s.prompting = false; + } + if (!(await this.resumed(s))) { + // The user left the app while (or right after) the prompt was open. + if (this.session === s && !s.cancelled) this.abortQuietly(s); + return; + } + } + if (!this.current(s)) return; + if (!permission.granted) { + this.fail(s, "not-allowed"); + return; + } + const { installedLocales } = await this.deps.module.getSupportedLocales({}); + if (!this.current(s)) return; + const lang = selectInstalledLocale(locale, installedLocales); + if (!lang) { + const attempted = this.deps.downloads ?? downloads; + const key = locale.toLowerCase(); + if (!attempted.has(key)) { + attempted.add(key); + void this.deps.module + .androidTriggerOfflineModelDownload({ locale }) + .catch(() => undefined); + } + this.fail(s, "language-not-supported"); + return; + } + this.lease = s; + await this.deps.audio.acquire(() => this.current(s)); + if (!this.current(s)) { + this.finish(s); + return; + } + const cleanup = (subscription: { remove: () => void }) => + s.remove.push(() => subscription.remove()); + const on: SpeechModule["addListener"] = this.deps.module.addListener.bind(this.deps.module); + cleanup(on("start", () => this.emit(s, { state: "listening" }))); + cleanup( + on("result", (event) => { + const text = event.results[0]?.transcript.slice(0, 512); + if (text) this.emit(s, { state: event.isFinal ? "final" : "partial", text }); + }), + ); + cleanup( + on("error", (event) => { + s.nativeStopped = true; + this.fail(s, mapVoiceError(event.error), false); + }), + ); + cleanup(on("nomatch", () => this.fail(s, "no-speech", false))); + cleanup( + on("end", () => { + this.emit(s, { state: "ended" }); + this.finish(s); + }), + ); + s.started = true; + this.deps.module.start({ + lang, + requiresOnDeviceRecognition: true, + interimResults: true, + continuous: false, + maxAlternatives: 1, + addsPunctuation: false, + recordingOptions: { persist: false }, + }); + if (this.current(s)) s.timers.push(this.clock.setTimeout(() => this.stop(id), 15000)); + } catch { + this.fail(s, "generic"); + } + } + stop(id: string): void { + const s = this.session; + if (!s || s.id !== id || s.cancelled || s.stopping) return; + if (!s.started) { + this.cancel(id); + return; + } + s.stopping = true; + // The user finished; if no final result arrives in time, end quietly + // rather than reporting a failure they did not cause. + s.timers.push(this.clock.setTimeout(() => this.abortQuietly(s), 3000)); + try { + this.deps.module.stop(); + } catch { + this.fail(s, "generic"); + } + } + cancel(id?: string): void { + const s = this.session; + if (!s || s.cancelled || (id !== undefined && s.id !== id)) return; + this.abortQuietly(s); + } + /** + * The app left the foreground. Cancels like `cancel()`, except while the OS + * permission prompt is open: that prompt is what paused the app, and nothing + * is being recorded yet. `start` re-checks the foreground once it closes. + */ + suspend(): void { + if (this.session?.prompting) return; + this.cancel(); + } +} diff --git a/apps/mobile/src/voice/audioArbiter.test.ts b/apps/mobile/src/voice/audioArbiter.test.ts new file mode 100644 index 000000000..ce912ab43 --- /dev/null +++ b/apps/mobile/src/voice/audioArbiter.test.ts @@ -0,0 +1,85 @@ +import { createAudioArbiter } from "./audioArbiter"; + +const cue = (cueId: string) => ({ cueId, text: "Turn left", locale: "en" as const }); +function fixture(speaking = false) { + let now = 0; + const audio = { + speak: jest.fn(async () => "spoken" as const), + stop: jest.fn(async () => {}), + getStatus: jest.fn(async () => ({ + initialized: true, + speaking, + localeAvailable: true, + lastResultCode: null, + })), + }; + const arbiter = createAudioArbiter( + audio, + () => now, + async (ms) => { + now += ms; + }, + ); + return { + audio, + arbiter, + age: (ms: number) => { + now += ms; + }, + }; +} +it("holds only the latest cue and releases it", async () => { + const f = fixture(); + await f.arbiter.acquire(() => true); + await f.arbiter.speak(cue("a")); + await f.arbiter.speak(cue("b")); + expect(f.audio.speak).not.toHaveBeenCalled(); + f.arbiter.release(); + expect(f.audio.speak).toHaveBeenCalledWith(cue("b")); +}); +it("drops stale guidance with stable status", async () => { + const f = fixture(); + await f.arbiter.acquire(() => true); + await f.arbiter.speak(cue("a")); + f.age(10001); + f.arbiter.release(); + expect(f.audio.speak).not.toHaveBeenCalled(); + expect((await f.arbiter.getStatus()).lastResultCode).toBe("voice-guidance-stale"); +}); +it("waits at most six seconds for speaking guidance then stops it", async () => { + const f = fixture(true); + f.audio.stop.mockImplementation(async () => { + f.audio.getStatus.mockResolvedValue({ + initialized: true, + speaking: false, + localeAvailable: true, + lastResultCode: null, + }); + }); + await f.arbiter.acquire(() => true); + expect(f.audio.stop).toHaveBeenCalledTimes(1); + expect(f.audio.getStatus).toHaveBeenCalledTimes(122); +}); +it("waits for guidance completion then flushes the native queue", async () => { + const f = fixture(true); + f.audio.getStatus + .mockResolvedValueOnce({ + initialized: true, + speaking: true, + localeAvailable: true, + lastResultCode: null, + }) + .mockResolvedValue({ + initialized: true, + speaking: false, + localeAvailable: true, + lastResultCode: null, + }); + await f.arbiter.acquire(() => true); + expect(f.audio.stop).toHaveBeenCalledTimes(1); +}); + +it("refuses recognition if guidance does not stop", async () => { + const f = fixture(true); + await expect(f.arbiter.acquire(() => true)).rejects.toThrow("guidance-still-speaking"); +}); diff --git a/apps/mobile/src/voice/audioArbiter.ts b/apps/mobile/src/voice/audioArbiter.ts new file mode 100644 index 000000000..44e748464 --- /dev/null +++ b/apps/mobile/src/voice/audioArbiter.ts @@ -0,0 +1,75 @@ +import { + type NavigationAudio, + type SpeakRequest, + validateSpeakRequest, +} from "../audio/navigationAudio"; + +export interface VoiceAudioLease { + acquire(isCurrent: () => boolean): Promise; + release(): void; +} + +/** One runtime-wide gate, including callers outside the foreground shell. */ +export function createAudioArbiter( + audio: NavigationAudio, + now = Date.now, + delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), +): NavigationAudio & VoiceAudioLease { + let held = false; + let latest: { request: SpeakRequest; at: number } | null = null; + let outcome: string | null = null; + let pendingSpeech = 0; + async function speak(request: SpeakRequest) { + pendingSpeech += 1; + try { + return await audio.speak(request); + } finally { + pendingSpeech -= 1; + } + } + return { + async speak(request) { + validateSpeakRequest(request); + if (held) { + latest = { request, at: now() }; + outcome = "voice-guidance-held"; + return "skipped"; + } + return speak(request); + }, + async stop() { + latest = null; + await audio.stop(); + }, + async getStatus() { + return { ...(await audio.getStatus()), ...(outcome ? { lastResultCode: outcome } : {}) }; + }, + async acquire(isCurrent) { + held = true; + const deadline = now() + 6000; + while (isCurrent() && (pendingSpeech > 0 || (await audio.getStatus()).speaking)) { + if (now() >= deadline) break; + await delay(50); + } + if (!isCurrent()) return; + // Android's status can become idle when the first queued cue ends. + // Flush any remaining native queue before acquiring the microphone. + await audio.stop(); + if (pendingSpeech > 0 || (await audio.getStatus()).speaking) { + throw new Error("guidance-still-speaking"); + } + }, + release() { + held = false; + const cue = latest; + latest = null; + if (!cue) return; + if (now() - cue.at > 10000) { + outcome = "voice-guidance-stale"; + return; + } + outcome = "voice-guidance-released"; + void speak(cue.request); + }, + }; +} diff --git a/apps/ops-agent/Dockerfile b/apps/ops-agent/Dockerfile index 7aee7914c..88726df8d 100644 --- a/apps/ops-agent/Dockerfile +++ b/apps/ops-agent/Dockerfile @@ -10,6 +10,8 @@ FROM base AS deps WORKDIR /app ENV HUSKY=0 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +# pnpm checks every patched dependency's file hash, installed here or not. +COPY patches/ patches/ # Only the workspace packages the agent and its fixed CLI reach. Manifests stay # separate from source, so a source edit keeps the dependency install cached. COPY --parents \ diff --git a/apps/transitous-runner/Dockerfile b/apps/transitous-runner/Dockerfile index 7f13eba4a..71dc776fc 100644 --- a/apps/transitous-runner/Dockerfile +++ b/apps/transitous-runner/Dockerfile @@ -33,6 +33,8 @@ FROM base AS deps WORKDIR /app ENV HUSKY=0 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +# pnpm checks every patched dependency's file hash, installed here or not. +COPY patches/ patches/ # Only the workspace packages the runner reaches. Manifests stay separate from # source, so a source edit keeps the dependency install cached. COPY --parents \ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index f94da61e3..836c566f9 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -12,6 +12,8 @@ RUN corepack enable && \ FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +# pnpm checks every patched dependency's file hash, installed here or not. +COPY patches/ patches/ COPY packages/command-palette/package.json packages/command-palette/ COPY packages/core/package.json packages/core/ COPY packages/extension-contracts/package.json packages/extension-contracts/ diff --git a/apps/web/src/app/(legal)/privacy/content.de.tsx b/apps/web/src/app/(legal)/privacy/content.de.tsx index 94d1da636..b69cd1222 100644 --- a/apps/web/src/app/(legal)/privacy/content.de.tsx +++ b/apps/web/src/app/(legal)/privacy/content.de.tsx @@ -866,23 +866,36 @@ export default function PrivacyContentDe({ Sprachsuche (Mikrofon) - Die Suchleiste bietet optional eine Spracheingabe. Sie ist erst aktiv, wenn Sie die - Schaltfläche drücken. Danach fragt Ihr Browser die Mikrofonberechtigung ab und wandelt - Ihre Spracheingabe mit der browsereigenen Spracherkennung (Web Speech API) in Suchtext um. - An unsere Server gelangt ausschließlich der resultierende Text als gewöhnliche Suchanfrage - — die Audiodaten erhalten und speichern wir nicht. + Auf der Website bietet die Suchleiste optional eine Spracheingabe. Sie ist erst aktiv, + wenn Sie die Schaltfläche drücken. Danach fragt Ihr Browser die Mikrofonberechtigung ab + und wandelt Ihre Spracheingabe mit der browsereigenen Spracherkennung (Web Speech API) in + Suchtext um. An unsere Server gelangt ausschließlich der resultierende Text als + gewöhnliche Suchanfrage — die Audiodaten erhalten und speichern wir nicht. - Wo die Spracherkennung stattfindet, entscheidet Ihr Browser, nicht OpenMapX. - Chromium-basierte Browser (Chrome, Edge und die meisten Abkömmlinge) übertragen die - aufgenommenen Audiodaten zur Transkription an den Cloud-Sprachdienst ihres Herstellers, - für den dessen eigene Datenschutzerklärung gilt; andere Browser erkennen die Sprache - gegebenenfalls lokal auf Ihrem Gerät oder bieten die Funktion nicht an — dann wird die - Schaltfläche nicht angezeigt. Wenn Sie eine Verarbeitung durch Ihren Browserhersteller + Auf der Website entscheidet Ihr Browser, wo die Spracherkennung stattfindet, nicht + OpenMapX. Chromium-basierte Browser (Chrome, Edge und die meisten Abkömmlinge) übertragen + die aufgenommenen Audiodaten zur Transkription an den Cloud-Sprachdienst ihres + Herstellers, für den dessen eigene Datenschutzerklärung gilt; andere Browser erkennen die + Sprache gegebenenfalls lokal auf Ihrem Gerät oder bieten die Funktion nicht an — dann wird + die Schaltfläche nicht angezeigt. Wenn Sie eine Verarbeitung durch Ihren Browserhersteller nicht wünschen, nutzen Sie die Sprachschaltfläche nicht; die Eingabe über die Tastatur verwendet kein Mikrofon. + + In der installierten App nutzt die optionale Sprachsuche ab Android 13 die lokale + Spracherkennung des Betriebssystems, sofern ein lokaler Dienst und das Sprachmodell + verfügbar sind. Sie startet erst, wenn Sie die Mikrofontaste drücken und Android den + Mikrofonzugriff erlauben. Die Audiodaten werden auf Ihrem Gerät verarbeitet und von + OpenMapX weder übertragen noch gespeichert. Das Herunterladen eines fehlenden + Sprachmodells benötigt eine Netzwerkverbindung; die Erkennung selbst nicht. Unter iOS und + älteren Android-Versionen ist die Sprachsuche deaktiviert. Das Transkript wird als + gewöhnlicher Suchtext verwendet. Dafür gelten dieselben Regeln für Suchanfragen und den + Suchverlauf wie bei getipptem Text. Die Spracheingabefunktion protokolliert keine + Transkripte. + + Hinweis zum Datenfluss: Die Spalte "Datenzugriff" oben zeigt an, wie jeder Dienst kontaktiert wird. "Nur Server" und "Über Server diff --git a/apps/web/src/app/(legal)/privacy/content.en.tsx b/apps/web/src/app/(legal)/privacy/content.en.tsx index 514f121ee..0479cfce2 100644 --- a/apps/web/src/app/(legal)/privacy/content.en.tsx +++ b/apps/web/src/app/(legal)/privacy/content.en.tsx @@ -799,20 +799,31 @@ export default function PrivacyContent({ Voice Search (Microphone) - The search bar offers an optional voice input button. It is never active until you press - it. When you do, your browser asks for microphone permission and then uses your - browser's own built-in speech recognition (the Web Speech API) to turn what you say - into search text. Only the resulting text reaches our servers, as an ordinary search query - — the audio itself is never sent to us and is never stored by us. + On the website, the search bar offers an optional voice input button. It is never active + until you press it. When you do, your browser asks for microphone permission and then uses + your browser's own built-in speech recognition (the Web Speech API) to turn what you + say into search text. Only the resulting text reaches our servers, as an ordinary search + query — the audio itself is never sent to us and is never stored by us. - Where the speech recognition itself happens is decided by your browser, not by OpenMapX. - Chromium-based browsers (Chrome, Edge and most derivatives) transmit the captured audio to - their vendor's cloud speech service for transcription, under that vendor's own - privacy policy; other browsers may transcribe on your device or may not offer the feature - at all, in which case the button is hidden. If you do not want your audio processed by - your browser vendor, do not use the voice button — typing your query has no microphone - involvement. + On the website, where speech recognition happens is decided by your browser, not by + OpenMapX. Chromium-based browsers (Chrome, Edge and most derivatives) transmit the + captured audio to their vendor's cloud speech service for transcription, under that + vendor's own privacy policy; other browsers may transcribe on your device or may not + offer the feature at all, in which case the button is hidden. If you do not want your + audio processed by your browser vendor, do not use the voice button — typing your query + has no microphone involvement. + + + + In the installed app, optional voice search uses the operating system's on-device + recogniser on Android 13 or later when an on-device service and the language model are + available. It starts only when you press the microphone button and grant Android + microphone permission. The audio is processed on your device and is not transmitted or + stored by OpenMapX. Downloading a missing language model requires a network connection; + recognition itself does not. Voice search is disabled on iOS and older Android versions. + The transcript is used as ordinary search text and follows the same search-request and + search-history rules as typed text. Transcripts are not logged by the voice-input feature. diff --git a/apps/web/src/components/search/VoiceSearchButton.test.tsx b/apps/web/src/components/search/VoiceSearchButton.test.tsx index 7b50696d1..43a38138f 100644 --- a/apps/web/src/components/search/VoiceSearchButton.test.tsx +++ b/apps/web/src/components/search/VoiceSearchButton.test.tsx @@ -1,8 +1,16 @@ +import type { NativeToWebMessage } from "@openmapx/core/navigation"; import { describe, expect, it, vi } from "vitest"; +import { BROWSER_RUNTIME, type MobileRuntime } from "@/lib/mobile/MobileRuntimeProvider"; import { CHANNEL_GLOBAL } from "@/lib/mobile/mobileShellEnvironment"; import { act, fireEvent, render, screen, waitFor } from "@/test"; import { VoiceSearchButton } from "./VoiceSearchButton"; +const runtime = vi.hoisted(() => ({ current: null as MobileRuntime | null })); +vi.mock("@/lib/mobile/MobileRuntimeProvider", async (original) => { + const actual = await original(); + return { ...actual, useMobileRuntimeContext: () => runtime.current ?? actual.BROWSER_RUNTIME }; +}); + vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); /** Fake Web Speech recognition that records the constructed instance. */ @@ -149,17 +157,201 @@ describe("VoiceSearchButton", () => { }); describe("VoiceSearchButton inside the installed shell", () => { - it("offers no microphone even where the browser would support one", async () => { - installFakeRecognition(); + it.each(["negotiating", "native-incompatible", "native-error", "native-compatible"] as const)( + "hides without capability in %s", + async (state) => { + installFakeRecognition(); + runtime.current = { + ...BROWSER_RUNTIME, + state, + handshake: + state === "native-compatible" + ? null + : ({ capabilities: {} } as MobileRuntime["handshake"]), + }; + (globalThis as Record)[CHANNEL_GLOBAL] = { nonce: "abc123" }; + try { + const { queryByLabelText } = render(); + await waitFor(() => expect(queryByLabelText("search.voiceSearchAriaLabel")).toBeNull()); + } finally { + runtime.current = null; + delete (globalThis as Record)[CHANNEL_GLOBAL]; + uninstall(); + } + }, + ); + + function nativeRuntime() { + const listeners = new Set<(event: NativeToWebMessage) => void>(); + const send = vi.fn(); + const request = vi + .fn() + .mockResolvedValue({ type: "voice.availability", payload: { available: true } }); + runtime.current = { + ...BROWSER_RUNTIME, + state: "native-compatible", + isInstalledShell: true, + client: { + send, + request, + subscribe: (listener: (event: NativeToWebMessage) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + } as unknown as MobileRuntime["client"], + handshake: { capabilities: {} } as MobileRuntime["handshake"], + }; (globalThis as Record)[CHANNEL_GLOBAL] = { nonce: "abc123" }; + const emit = (state: string, text?: string, error?: string) => { + const requestId = (send.mock.calls[0]?.[1] as { requestId: string }).requestId; + for (const listener of listeners) + listener({ + type: "voice.event", + payload: { requestId, state, text, error }, + } as NativeToWebMessage); + }; + if (!runtime.current.client) throw new Error("missing test client"); + return { send, emit, listeners, client: runtime.current.client, request }; + } + + it("stays hidden until a positive probe, and probes again for a new client or handshake", async () => { + const h = nativeRuntime(); + let resolve!: (value: unknown) => void; + h.request.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + try { + const view = render(); + expect(screen.queryByLabelText("search.voiceSearchAriaLabel")).toBeNull(); + expect(h.client.request).toHaveBeenCalledWith("voice.probe", {}); + view.rerender(); + expect(h.client.request).toHaveBeenCalledTimes(1); + await act(async () => resolve({ type: "voice.availability", payload: { available: true } })); + await screen.findByLabelText("search.voiceSearchAriaLabel"); + const next = nativeRuntime(); + next.request.mockResolvedValue({ + type: "voice.availability", + payload: { available: false }, + } as NativeToWebMessage); + view.rerender(); + expect(screen.queryByLabelText("search.voiceSearchAriaLabel")).toBeNull(); + await waitFor(() => expect(next.client.request).toHaveBeenCalledTimes(1)); + if (!runtime.current?.handshake) throw new Error("missing test handshake"); + runtime.current = { ...runtime.current, handshake: { ...runtime.current.handshake } }; + view.rerender(); + await waitFor(() => expect(next.client.request).toHaveBeenCalledTimes(2)); + } finally { + runtime.current = null; + delete (globalThis as Record)[CHANNEL_GLOBAL]; + } + }); + + it("ignores a positive probe from a replaced client and never probes outside the shell", async () => { + const old = nativeRuntime(); + let resolve!: (value: unknown) => void; + old.request.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); try { - const { queryByLabelText } = render(); - // A store build that asks for a permission it never declared is a - // rejection, so the control cannot exist at all. - await waitFor(() => expect(queryByLabelText("search.voiceSearchAriaLabel")).toBeNull()); + const view = render(); + const next = nativeRuntime(); + next.request.mockResolvedValue({ + type: "voice.availability", + payload: { available: false }, + } as NativeToWebMessage); + view.rerender(); + await act(async () => resolve({ type: "voice.availability", payload: { available: true } })); + expect(screen.queryByLabelText("search.voiceSearchAriaLabel")).toBeNull(); + next.request.mockClear(); + runtime.current = { ...(runtime.current as MobileRuntime), isInstalledShell: false }; + view.rerender(); + expect(next.client.request).not.toHaveBeenCalled(); } finally { + runtime.current = null; + delete (globalThis as Record)[CHANNEL_GLOBAL]; + } + }); + + it.each(["false", "timeout", "rejection"])( + "keeps an old-APK-shaped hello hidden on probe %s", + async (outcome) => { + const h = nativeRuntime(); // Handshake has no voice field. + if (outcome === "false") + h.request.mockResolvedValue({ + type: "voice.availability", + payload: { available: false }, + } as NativeToWebMessage); + else h.request.mockRejectedValue(new Error(outcome)); + try { + render(); + await waitFor(() => expect(h.client.request).toHaveBeenCalledTimes(1)); + expect(screen.queryByLabelText("search.voiceSearchAriaLabel")).toBeNull(); + } finally { + runtime.current = null; + delete (globalThis as Record)[CHANNEL_GLOBAL]; + } + }, + ); + + it("uses native interim/final results, stop preserves final, unmount cancels without browser capture", async () => { + const rec = installFakeRecognition(); + const capture = vi.fn(); + setMediaDevices(capture); + const h = nativeRuntime(); + const onResult = vi.fn(); + try { + const view = render(); + const button = await screen.findByLabelText("search.voiceSearchAriaLabel"); + fireEvent.click(button); + expect(h.send).toHaveBeenCalledWith("voice.start", { + requestId: expect.any(String), + locale: expect.stringContaining("-"), + }); + act(() => h.emit("partial", "Berlin")); + expect(onResult).toHaveBeenLastCalledWith("Berlin", false); + fireEvent.click(button); + expect(h.send.mock.calls.at(-1)?.[0]).toBe("voice.stop"); + act(() => h.emit("final", "Berlin Hbf")); + expect(onResult).toHaveBeenLastCalledWith("Berlin Hbf", true); + view.unmount(); + expect(h.send.mock.calls.at(-1)?.[0]).toBe("voice.cancel"); + act(() => h.emit("final", "late")); + expect(onResult).toHaveBeenCalledTimes(2); + expect(h.listeners.size).toBe(0); + expect(capture).not.toHaveBeenCalled(); + expect(rec.current).toBeNull(); + } finally { + runtime.current = null; delete (globalThis as Record)[CHANNEL_GLOBAL]; uninstall(); } }); + + it.each([ + ["not-allowed", "voiceErrorNotAllowedApp"], + ["language-not-supported", "voiceErrorLanguageApp"], + ["audio-capture", "voiceErrorNoMicrophone"], + ["no-speech", "voiceErrorNoSpeech"], + ["busy", "voiceErrorGeneric"], + ["unavailable", "voiceErrorGeneric"], + ["aborted", "voiceErrorGeneric"], + ["generic", "voiceErrorGeneric"], + ])("maps native %s to %s", async (error, key) => { + const h = nativeRuntime(); + try { + render(); + fireEvent.click(await screen.findByLabelText("search.voiceSearchAriaLabel")); + act(() => h.emit("error", undefined, error)); + await screen.findByText(`search.${key}`); + } finally { + runtime.current = null; + delete (globalThis as Record)[CHANNEL_GLOBAL]; + } + }); }); diff --git a/apps/web/src/components/search/VoiceSearchButton.tsx b/apps/web/src/components/search/VoiceSearchButton.tsx index c9fc9ab61..0528d7e9a 100644 --- a/apps/web/src/components/search/VoiceSearchButton.tsx +++ b/apps/web/src/components/search/VoiceSearchButton.tsx @@ -6,7 +6,8 @@ import IconButton from "@mui/material/IconButton"; import Snackbar from "@mui/material/Snackbar"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; -import { shellFeatureBoundary } from "@/lib/mobile/mobileShellEnvironment"; +import { isInstalledShell, shellFeatureBoundary } from "@/lib/mobile/mobileShellEnvironment"; +import { useNativeVoiceInput } from "@/lib/mobile/useNativeVoiceInput"; import { useHydrated } from "@/lib/useHydrated"; interface SpeechRecognitionAlternativeLike { @@ -97,7 +98,8 @@ export interface VoiceSearchButtonProps { } /** - * Microphone button that drives voice search via the Web Speech API. Renders + * Microphone button using native on-device input in a capable shell and the + * Web Speech API in the browser. The browser path renders * nothing until the browser is confirmed to expose SpeechRecognition (resolved * in an effect, not during render, so the first client render matches the * server — which has no `window` — and avoids a hydration mismatch; the button @@ -107,9 +109,9 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { const t = useTranslations("search"); const locale = useLocale(); - // The installed shell declares no microphone use, so asking for one there is - // a store rejection rather than a feature. Defer the browser-only lookup - // until after hydration so the initial client render still matches SSR. + const nativeInput = useNativeVoiceInput(); + const inShell = isInstalledShell(); + // Browser capture remains forbidden in the shell, even before negotiation. const speechCtor = useHydrated() && shellFeatureBoundary().microphone ? getSpeechRecognition() : undefined; @@ -130,6 +132,27 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { }, [onResult]); const startVoiceSearch = useCallback(() => { + if (inShell) { + if (!nativeInput) return; + setVoiceError(null); + setListening(true); + nativeInput.start(speechLang(locale), { + onPartial: (text) => onResultRef.current(text, false), + onFinal: (text) => onResultRef.current(text, true), + onEnd: () => setListening(false), + onError: (error) => { + setListening(false); + const key = + error === "not-allowed" + ? "voiceErrorNotAllowedApp" + : error === "language-not-supported" + ? "voiceErrorLanguageApp" + : voiceErrorKey(error); + setVoiceError(t(key)); + }, + }); + return; + } if (!speechCtor) return; setVoiceError(null); const session = ++voiceSessionRef.current; @@ -209,17 +232,22 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { if (voiceSessionRef.current !== session) return; // cancelled meanwhile beginRecognition(); }); - }, [speechCtor, locale, t]); + }, [speechCtor, locale, t, inShell, nativeInput]); const toggleVoiceSearch = useCallback(() => { if (listening) { + if (inShell) { + nativeInput?.stop(); + setListening(false); + return; + } voiceSessionRef.current++; // invalidate any pending getUserMedia preflight recognitionRef.current?.stop(); setListening(false); } else { startVoiceSearch(); } - }, [listening, startVoiceSearch]); + }, [listening, startVoiceSearch, inShell, nativeInput]); // Stop recognition if the component unmounts mid-listen (and invalidate any // in-flight getUserMedia preflight so it can't start recognition after unmount). @@ -231,7 +259,7 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { [], ); - if (!speechCtor) return null; + if (inShell ? !nativeInput : !speechCtor) return null; return ( <> diff --git a/apps/web/src/lib/mobile/bridgeClient.test.ts b/apps/web/src/lib/mobile/bridgeClient.test.ts index 699023052..0f9cbee44 100644 --- a/apps/web/src/lib/mobile/bridgeClient.test.ts +++ b/apps/web/src/lib/mobile/bridgeClient.test.ts @@ -125,6 +125,84 @@ function snapshotMessage(revision: number) { }; } +describe("voice dispatch", () => { + it("correlates availability to the probe, leaving unrelated requests pending", async () => { + const h = harness(); + h.client.attach(); + const ready = h.client.hello(); + deliverHello(h); // Old APK hello has no voice capability. + await ready; + const first = h.client.request("voice.probe", {}); + const firstId = JSON.parse(h.sent.at(-1) ?? "").messageId; + const second = h.client.request("voice.probe", {}); + const secondId = JSON.parse(h.sent.at(-1) ?? "").messageId; + const reply = helloReply(firstId); + h.deliver({ + ...reply, + type: "voice.availability", + payload: { forMessageId: secondId, available: false }, + }); + expect((await second).payload).toEqual({ forMessageId: secondId, available: false }); + expect(h.timerCount()).toBe(1); + h.deliver({ + ...reply, + type: "voice.availability", + payload: { forMessageId: firstId, available: true }, + }); + expect((await first).type).toBe("voice.availability"); + expect(h.timerCount()).toBe(0); + }); + + it("times out an unanswered old-APK probe without losing negotiation", async () => { + const h = harness(); + h.client.attach(); + const ready = h.client.hello(); + deliverHello(h); + const handshake = await ready; + const probe = h.client.request("voice.probe", {}); + const rejected = expect(probe).rejects.toMatchObject({ code: "timeout" }); + h.advance(READ_TIMEOUT_MS); + await rejected; + expect(h.client.negotiated).toBe(handshake); + }); + + it("validates fire-and-forget commands and broadcasts uncorrelated voice events", async () => { + const h = harness(); + h.client.attach(); + const ready = h.client.hello(); + deliverHello(h); + expect((await ready).capabilities).not.toHaveProperty("speechInput"); + const received: unknown[] = []; + const unsubscribe = h.client.subscribe((event) => received.push(event)); + for (const type of ["voice.start", "voice.stop", "voice.cancel"] as const) { + h.client.send(type, { + requestId: "r", + ...(type === "voice.start" ? { locale: "de-DE" } : {}), + }); + expect(JSON.parse(h.sent.at(-1) ?? "").type).toBe(type); + } + expect(h.client.pendingCount).toBe(0); + expect(() => h.client.send("voice.start", { requestId: "r", locale: "de_DE" })).toThrow( + BridgeError, + ); + const event = { + ...snapshotMessage(1), + type: "voice.event", + payload: { requestId: "r", state: "partial", text: "Berlin" }, + }; + h.deliver({ ...event, channelNonce: "old" }); + h.deliver({ ...event, protocolVersion: MOBILE_PROTOCOL_MAX + 1 }); + h.deliver({ ...event, payload: { ...event.payload, extra: true } }); + expect(received).toEqual([]); + h.deliver(event); + expect(received).toEqual([event]); + unsubscribe(); + h.deliver(event); + expect(received).toHaveLength(1); + h.client.close(); + }); +}); + describe("BridgeClient.attach", () => { it("attaches inside the shell", () => { const { client } = harness(); diff --git a/apps/web/src/lib/mobile/bridgeClient.ts b/apps/web/src/lib/mobile/bridgeClient.ts index 56726249f..32589b482 100644 --- a/apps/web/src/lib/mobile/bridgeClient.ts +++ b/apps/web/src/lib/mobile/bridgeClient.ts @@ -96,6 +96,7 @@ type RequestOptions = { const EXPECTED_REPLY: Partial> = { "web.hello": "native.hello", + "voice.probe": "voice.availability", "session.prepare": "session.prepared", "session.start": "session.started", "session.replace": "session.replaced", diff --git a/apps/web/src/lib/mobile/mobileShellEnvironment.ts b/apps/web/src/lib/mobile/mobileShellEnvironment.ts index 94ee5a1cb..a346f9a18 100644 --- a/apps/web/src/lib/mobile/mobileShellEnvironment.ts +++ b/apps/web/src/lib/mobile/mobileShellEnvironment.ts @@ -3,7 +3,7 @@ * * Deliberately synchronous and answerable on the very first render. Several * decisions depend on it that cannot wait for a handshake: whether to offer - * microphone input and whether the browser navigation engine may start at all. + * browser microphone input and whether the browser navigation engine may start at all. * * So the descriptor the shell injects before any page script is the authority * here, not the handshake. A page that sees the descriptor is in the shell, even @@ -63,7 +63,7 @@ export function isInstalledShell(scope: unknown = globalThis): boolean { * depend on the protocol version, so none of them wait for a handshake. */ export interface ShellFeatureBoundary { - /** Browser microphone access, including voice search. */ + /** Browser microphone access; native voice input is a separate capability. */ microphone: boolean; /** The browser's own geolocation watch. */ browserGeolocationWatch: boolean; @@ -87,8 +87,7 @@ const BROWSER_BOUNDARY: ShellFeatureBoundary = { }; const SHELL_BOUNDARY: ShellFeatureBoundary = { - // The shell declares no microphone use, and a store build that asks for one it - // never declared is a rejection. + // Native owns voice input. The embedded page never captures microphone audio. microphone: false, // Native owns the one location stream. A second producer would race it. browserGeolocationWatch: false, diff --git a/apps/web/src/lib/mobile/nativeVoiceInput.test.ts b/apps/web/src/lib/mobile/nativeVoiceInput.test.ts new file mode 100644 index 000000000..1d6e395ce --- /dev/null +++ b/apps/web/src/lib/mobile/nativeVoiceInput.test.ts @@ -0,0 +1,78 @@ +import type { NativeToWebMessage } from "@openmapx/core/navigation"; +import { describe, expect, it, vi } from "vitest"; +import { NativeVoiceInput } from "./nativeVoiceInput"; + +function harness() { + const listeners = new Set<(message: NativeToWebMessage) => void>(); + const send = vi.fn(); + const input = new NativeVoiceInput({ + send, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + const callbacks = { onPartial: vi.fn(), onFinal: vi.fn(), onEnd: vi.fn(), onError: vi.fn() }; + const emit = (requestId: string, state: string, text?: string, error?: string) => { + const message = { + type: "voice.event", + payload: { requestId, state, text, error }, + } as NativeToWebMessage; + for (const listener of listeners) listener(message); + }; + const id = () => (send.mock.calls.at(-1)?.[1] as { requestId: string }).requestId; + return { input, callbacks, emit, id, send, listeners }; +} + +describe("NativeVoiceInput", () => { + it("keeps results after stop, filters foreign events and cleans up on end", () => { + const h = harness(); + h.input.start("de-DE", h.callbacks); + const id = h.id(); + h.emit("foreign", "final", "wrong"); + h.emit(id, "partial", "Berlin"); + h.input.stop(); + expect(h.send).toHaveBeenLastCalledWith("voice.stop", { requestId: id }); + h.emit(id, "final", "Berlin Hbf"); + h.emit(id, "ended"); + h.emit(id, "final", "late"); + expect(h.callbacks.onPartial).toHaveBeenCalledTimes(1); + expect(h.callbacks.onPartial).toHaveBeenCalledWith("Berlin"); + expect(h.callbacks.onFinal).toHaveBeenCalledTimes(1); + expect(h.callbacks.onFinal).toHaveBeenCalledWith("Berlin Hbf"); + expect(h.callbacks.onEnd).toHaveBeenCalledTimes(1); + expect(h.listeners.size).toBe(0); + }); + + it("cancels on replacement, uses a fresh id, discards late events and unsubscribes", () => { + const h = harness(); + h.input.start("en-US", h.callbacks); + const old = h.id(); + h.input.start("de-DE", h.callbacks); + const current = h.id(); + expect(current).not.toBe(old); + expect(h.send).toHaveBeenCalledWith("voice.cancel", { requestId: old }); + h.emit(old, "final", "old"); + h.input.cancel(); + h.emit(current, "final", "late"); + expect(h.callbacks.onFinal).not.toHaveBeenCalled(); + expect(h.listeners.size).toBe(0); + }); + + it("ends on error and releases a subscription if sending fails", () => { + const h = harness(); + h.input.start("en-US", h.callbacks); + h.emit(h.id(), "error", undefined, "busy"); + expect(h.callbacks.onError).toHaveBeenCalledTimes(1); + expect(h.callbacks.onError).toHaveBeenCalledWith("busy"); + expect(h.listeners.size).toBe(0); + h.send.mockImplementation(() => { + throw new Error("closed"); + }); + h.input.start("en-US", h.callbacks); + expect(h.callbacks.onError).toHaveBeenLastCalledWith("generic"); + expect(h.listeners.size).toBe(0); + }); +}); diff --git a/apps/web/src/lib/mobile/nativeVoiceInput.ts b/apps/web/src/lib/mobile/nativeVoiceInput.ts new file mode 100644 index 000000000..dec515bd6 --- /dev/null +++ b/apps/web/src/lib/mobile/nativeVoiceInput.ts @@ -0,0 +1,70 @@ +import type { VoiceEvent } from "@openmapx/core/navigation"; +import type { BridgeClient } from "./bridgeClient"; + +export interface NativeVoiceCallbacks { + onPartial(text: string): void; + onFinal(text: string): void; + onEnd(): void; + onError(error: NonNullable): void; +} + +/** Owns one utterance; stop preserves results, cancel revokes their delivery. */ +export class NativeVoiceInput { + private session: { requestId: string; unsubscribe: () => void } | null = null; + + constructor(private readonly client: Pick) {} + + start(locale: string, callbacks: NativeVoiceCallbacks): void { + this.cancel(); + const requestId = crypto.randomUUID(); + const unsubscribe = this.client.subscribe((message) => { + if (message.type !== "voice.event" || this.session?.requestId !== requestId) return; + const event = message.payload; + if (event.requestId !== requestId) return; + if (event.state === "partial" || event.state === "final") { + const text = event.text?.trim(); + if (text) { + if (event.state === "partial") callbacks.onPartial(text); + else callbacks.onFinal(text); + } + } else if (event.state === "ended" || event.state === "error") { + this.release(); + if (event.state === "error") callbacks.onError(event.error ?? "generic"); + else callbacks.onEnd(); + } + }); + this.session = { requestId, unsubscribe }; + try { + this.client.send("voice.start", { requestId, locale }); + } catch { + this.release(); + callbacks.onError("generic"); + } + } + + stop(): void { + if (!this.session) return; + try { + this.client.send("voice.stop", { requestId: this.session.requestId }); + } catch { + this.cancel(); + } + } + + cancel(): void { + const session = this.session; + this.release(); + if (!session) return; + try { + this.client.send("voice.cancel", { requestId: session.requestId }); + } catch { + // A replaced/closed document channel already revokes native ownership. + } + } + + private release(): void { + const session = this.session; + this.session = null; + session?.unsubscribe(); + } +} diff --git a/apps/web/src/lib/mobile/useNativeVoiceInput.ts b/apps/web/src/lib/mobile/useNativeVoiceInput.ts new file mode 100644 index 000000000..f3dee23ef --- /dev/null +++ b/apps/web/src/lib/mobile/useNativeVoiceInput.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useMobileRuntimeContext } from "./MobileRuntimeProvider"; +import { NativeVoiceInput } from "./nativeVoiceInput"; + +export function useNativeVoiceInput() { + const runtime = useMobileRuntimeContext(); + const client = + runtime.isInstalledShell && runtime.state === "native-compatible" && runtime.handshake + ? runtime.client + : null; + const handshake = runtime.handshake; + const candidate = useMemo( + () => (client && handshake ? new NativeVoiceInput(client) : null), + [client, handshake], + ); + const [available, setAvailable] = useState(null); + useEffect(() => { + if (!client || !candidate) return; + let active = true; + void client + .request("voice.probe", {}) + .then((reply) => { + if (active && reply.type === "voice.availability" && reply.payload.available) + setAvailable(candidate); + }) + .catch(() => { + /* Older shells silently reject unknown commands. */ + }); + return () => { + active = false; + candidate.cancel(); + }; + }, [client, candidate]); + // A previous document's positive reply never enables a new client/handshake. + return available === candidate ? candidate : null; +} diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 8f6d5a21e..f833653d4 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -37,17 +37,17 @@ records. ## The authority matrix -| Concern | Browser or PWA | Installed shell | -| --------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------- | -| Rendering, search and route planning | Web UI | Web UI | -| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | -| Ground and transit navigation | Browser engine | Native coordinator, reported available | -| Background location, local alerts and speech | Subject to browser limits | Native; alerts need the notification grant | -| Durable native session | Not used | SQLite session, resumed after a process restart | -| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | -| Deep-link delivery | Browser flows | OS URLs validated, queued through document loading, delivered after bridge negotiation | -| Community frontend bundles | Disabled | Disabled | -| Microphone input | Subject to browser support and permission | Disabled | +| Concern | Browser or PWA | Installed shell | +| --------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Rendering, search and route planning | Web UI | Web UI | +| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | +| Ground and transit navigation | Browser engine | Native coordinator, reported available | +| Background location, local alerts and speech | Subject to browser limits | Native; alerts need the notification grant | +| Durable native session | Not used | SQLite session, resumed after a process restart | +| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | +| Deep-link delivery | Browser flows | OS URLs validated, queued through document loading, delivered after bridge negotiation | +| Community frontend bundles | Disabled | Disabled | +| Microphone input | Subject to browser support and permission | Native on-device search on Android 13+ when the voice probe answers available; WebView capture disabled | The immutable descriptor is injected before page scripts run. Shell restrictions use that descriptor, so they apply during negotiation, incompatibility and errors. @@ -139,6 +139,12 @@ versions. Define and test their overlap, deploy a compatible web build, then shi the native build and enable the new capability. The current first-candidate configuration has no older version in its compatibility matrix. +Voice input uses a correlated `voice.probe` / `voice.availability` exchange after +negotiation, without changing either strict hello payload or the protocol version. +New APK + old web: hello is unchanged and old web never probes. Old APK + new web: +the unknown probe is silently rejected and times out, keeping voice hidden while +navigation remains negotiated. iOS and Android below API 33 answer unavailable. + ### What crosses, and what does not The bridge carries bounded commands, route and itinerary data, snapshots and @@ -160,6 +166,19 @@ only to its matching session, route and base revision; a gap requests a full snapshot. The snapshot reducer and tests implement this contract independently of the unfinished foreground composition. +### Voice input messages + +- Web → native: `voice.probe` requests availability; native replies `voice.availability` with `forMessageId` and boolean `available`. +- Web → native: `voice.start` carries `requestId` and `locale`. +- Web → native: `voice.stop` finishes the matching utterance; `voice.cancel` aborts it. +- Native → web: `voice.event` carries `requestId`, state and bounded transcript text or an error code. + +Start, stop and cancel are fire-and-forget; events are subscription broadcasts. +The availability probe uses a correlated request with the default five-second read deadline. +The native recogniser owns microphone capture and sends only text to the page. +Guidance output uses `speech`; recognition uses the separate `voice.probe` availability check. +See [On-device voice input](./voice-input.md) for the design and pending device qualification. + ## Fixed origin `config/mobileConfig.ts` validates the origin at build time. Release origins must @@ -168,7 +187,11 @@ use HTTPS and pass its public-host checks. The official application identifier There is no runtime server selector. `App.tsx` applies a navigation allowlist, App-Bound Domains on iOS, disabled -WebView geolocation and denied media capture. Deep-link parsing reduces accepted +WebView geolocation and denied media capture. +The microphone boundary applies to browser capture, not native voice search. +iOS denies capture with `mediaCapturePermissionGrantType="deny"`; the Android +WebView patch denies audio and video capture. The web shell microphone flag +remains false even when native voice input is available. Deep-link parsing reduces accepted links to a map query or an active-navigation intent; parser support does not establish end-to-end OS link delivery in the current app. diff --git a/docs/docs/developer/mobile-feasibility.md b/docs/docs/developer/mobile-feasibility.md index 961417467..d053c6629 100644 --- a/docs/docs/developer/mobile-feasibility.md +++ b/docs/docs/developer/mobile-feasibility.md @@ -55,6 +55,21 @@ unit and Google Play car review are complete. The DHU screenshot slot in the runbook is reserved for inspected integration evidence, not synthetic browser fixtures. +## Voice input qualification + +The [native voice-input design](./voice-input.md) uses Android 13+ on-device +recognition behind the post-handshake `voice.probe` availability check. WebView microphone +capture remains denied. iOS and older Android versions keep voice input off. +Historical launch reports do not qualify this feature. + +On the candidate binary, test airplane-mode recognition with installed German +and English models, missing-model download and subsequent readiness, permission +grant/denial/permanent denial/revocation, and microphone release after stop, +cancel, backgrounding, screen off and document replacement. Check guidance cue +hold/release and WebView capture denial. Model downloads need a network even +though recognition does not. Accuracy and latency on a consented recording set +remain unmeasured. + ## The automated gate Install the locked dependencies first. For native policy tests, use macOS with diff --git a/docs/docs/developer/mobile-release.md b/docs/docs/developer/mobile-release.md index d61af1536..d31f7af06 100644 --- a/docs/docs/developer/mobile-release.md +++ b/docs/docs/developer/mobile-release.md @@ -34,8 +34,12 @@ there is no runtime server selector. ## The distribution boundary -The installed WebView is restricted to the compiled origin. Camera, microphone -and WebView geolocation are disabled. Community frontend runtime code is disabled +The installed WebView is restricted to the compiled origin. WebView camera, +microphone and geolocation access are disabled. Native on-device voice search +is designed for Android 13+ with an available on-device service; it does not +grant microphone access to the page. iOS voice input stays disabled. +See [On-device voice input](./voice-input.md) for the recognition boundary and +open device checks. Community frontend runtime code is disabled in the app, ordinary web app and PWA. Declarative metadata and permitted static assets remain supported; built-in integrations are part of the reviewed host. @@ -76,6 +80,11 @@ package for shell strings and cue formatting. Shared `@openmapx/core` and `@openmapx/mobility-core` code is Apache-2.0, but that does not remove the i18n constraint. +`expo-speech-recognition` 57.1.1 is a community dependency by jamsch under MIT. +The mobile dependency inventory reports licences but does not distribute notices, +and the web notice generator excludes mobile dependencies. A mobile distribution +notice remains a release gap. + ### The one decision required The intended Apache-2.0 shell requires a maintainer-approved licensing decision diff --git a/docs/docs/developer/mobile-store-operations.md b/docs/docs/developer/mobile-store-operations.md index 78fdbcf22..4601d9ae3 100644 --- a/docs/docs/developer/mobile-store-operations.md +++ b/docs/docs/developer/mobile-store-operations.md @@ -116,6 +116,23 @@ capabilities, including push, iCloud, Sign in with Apple, App Groups, HealthKit and CarPlay, disabled. Compare generated entitlements and the final archive with `compliance/permissions.ts`; do not infer them from a console checkbox alone. +## Voice-search disclosure and qualification + +Native voice search is designed for Android 13+ on-device recognition only. +It requests `RECORD_AUDIO` on a microphone tap; the WebView still cannot capture +audio or video. iOS voice input remains disabled despite the linked package +requiring microphone and speech-recognition purpose strings. + +Review the `voice-search-audio` practice and regenerated store worksheets with +the candidate. Audio is processed locally without OpenMapX transmission or +retention; the transcript follows normal search and search-history rules. +Model downloads require a network connection. + +Qualify airplane-mode recognition, German and English place names, permission +states, microphone release and guidance-audio coordination before making store +claims. Accuracy and latency measurements require a consented recording set. +See [On-device voice input](./voice-input.md) for the design and remaining checks. + ## What no script here does The scripts do not enroll an account, accept agreements, upload a build, submit diff --git a/docs/docs/developer/voice-input.md b/docs/docs/developer/voice-input.md new file mode 100644 index 000000000..5abe2a7b4 --- /dev/null +++ b/docs/docs/developer/voice-input.md @@ -0,0 +1,142 @@ +--- +title: On-device voice input +description: Native voice search decisions, microphone isolation, bridge contract and pending device qualification. +--- + +# On-device voice input + +The installed app uses the existing search microphone button and warning snackbar. +Interim transcripts fill the search field. +The final transcript submits through ordinary search, as on the website. +This page records the native design; device qualification remains open. +The website continues to use browser speech recognition, whose processing may use a vendor's servers. + +## Recogniser decision + +The native dependency is `expo-speech-recognition` **57.1.1**, licensed under MIT and maintained by jamsch. +It is a community package, not an official Expo package. +It wraps Android's `SpeechRecognizer` rather than bundling a separate recognition engine. + +| Alternative | Reason for rejection | +| -------------------------------- | --------------------------------------------------------------------------------------------------- | +| `@react-native-voice/voice` | Archived; does not provide the required strict on-device guarantee. | +| Vosk or whisper-based packages | Separate engines with large models rather than the OS recogniser requested for this feature. | +| Newer speech wrappers considered | Missing cancellation or final-result flags needed for lifecycle ownership and automatic submission. | + +The existing mobile dependency inventory reports package licences but does not distribute licence notices. +The web notice generator does not scan `apps/mobile`. +A mobile distribution notice for this MIT dependency remains a release gap; no new notice mechanism is introduced here. + +## Android guarantee and language readiness + +Voice input is enabled only on Android 13 or later (API 33+) with an available on-device service. +Every recognition request sets `requiresOnDeviceRecognition: true`. +On this path the pinned package calls `SpeechRecognizer.createOnDeviceSpeechRecognizer`, with no network recogniser fallback. +OpenMapX never sets `androidRecognitionServicePackage`. +On API 31–32 the package would only prefer offline recognition, so the capability stays off there. +Downloading a model requires a network connection; recognition itself does not. +This guarantee concerns recognition, not the subsequent search request. + +The web button requests a BCP-47 locale using the matching navigator region, or `en-US` / `de-DE` defaults. +Native readiness checks the on-device service's installed locales on each start. +It selects an exact match, then the first installed locale with the same primary language. +If neither exists, it returns `language-not-supported` and uses the existing warning that offline speech for this language is not installed. +It triggers Android's model download once per requested locale per app run. +Android owns the download dialog on API 33 and the background download on API 34+. +A download request is not proof that installation succeeded; the next start checks again. + +## iOS remains disabled + +The package's iOS path sets `requiresOnDeviceRecognition` only when supported and otherwise proceeds with server recognition. +The gap is tracked in upstream issue `jamsch/expo-speech-recognition#169`. +The app therefore reports `voice.availability` with `available: false` on iOS until upstream fails closed. +The linked package requires microphone and speech-recognition purpose strings in the iOS binary even while the feature is disabled. + +## Permission and lifecycle + +The first microphone tap requests Android's `RECORD_AUDIO` permission through the OS prompt. +Android shows that prompt as a separate activity, which reports the app as backgrounded. +The prompt therefore does not cancel the session; once it closes, the session waits up to two seconds for the app to return to the foreground before it starts listening. +A denial uses the existing snackbar directing the user to Android settings. +There is no custom rationale dialog or settings button, in keeping with the no-new-UI constraint. + +Each session owns one utterance, with continuous recognition disabled and a 15-second listening cap. +Tapping the listening button sends stop, which permits a final result. +Stop has a three-second terminal-event deadline, followed by a quiet abort if no final result arrives. +After an abort or a recogniser error, the session waits up to three seconds for Android's end event. +If it never arrives, the session finishes anyway, removing its listeners and releasing the guidance hold. +Such a session stays unconfirmed, because recogniser events carry no request ID. +The next start aborts it again and waits up to three seconds for its end or error; without one it answers `busy` rather than attach new listeners that could receive the old recogniser's output. +Cancellation discards results on backgrounding, screen off, page reload, document replacement, channel loss, load failure, a new start or app teardown. +Native ownership includes the document channel and `requestId`. +Both sides discard late results after ownership ends. +Voice events are never queued or replayed into a replacement document. + +## Guidance audio coordination + +Listening waits for a guidance utterance already in progress, for at most six seconds. +After that deadline, guidance is stopped before recognition starts. +While listening, the audio arbiter holds guidance cues and keeps only the latest. +When listening ends, it speaks that cue only if it is at most ten seconds old. +Older cues are dropped with a stable outcome, so stale directions are not spoken. +Foreground and headless navigation speech in the same runtime use this arbiter. + +## WebView isolation + +The WebView never receives microphone access. +The web shell descriptor keeps `shellFeatureBoundary().microphone` false, including before bridge negotiation. +iOS uses `mediaCapturePermissionGrantType="deny"`. +The Android `react-native-webview` patch denies both audio and video capture without requesting an OS grant. +The native recogniser owns the microphone; only transcript text crosses into the page. + +## Bridge and rollout + +Protocol v3 is unchanged. +After a compatible handshake, web sends `voice.probe` with strict empty payload `{}`. +Native replies with strict `voice.availability` payload `{ forMessageId, available: boolean }`, +correlated to the probe's envelope message ID. The web button stays hidden until a true reply; +false, rejection or the default five-second read deadline keeps it hidden. +A new client or renegotiated handshake probes again. Ordinary websites never probe. + +Neither hello payload changes: deployed web and APK parsers are strict, so even an optional +new capability key would break negotiation. New APK + old web keeps navigation working +because hello is unchanged and old web never probes. Old APK + new web silently rejects +the unknown probe before deduplication or dispatch (`NativeBridge.receive` on `origin/main`); +the unanswered probe times out without changing navigation state. Protocol version stays unchanged. +A web deployment can precede the APK because an unanswered probe only hides voice input. +The current parser accepts `voice.probe`, so the old-parser rejection is verified against +`origin/main` source rather than asserted against the new parser in a unit test. +There is no browser-recognition fallback inside the shell. + +| Direction | Message | Payload | +| ------------ | -------------------- | ---------------------------------------------------- | +| Web → native | `voice.probe` | Strict empty object; correlated availability request | +| Native → web | `voice.availability` | `forMessageId`, boolean `available` | +| Web → native | `voice.start` | `requestId`, BCP-47 `locale` (at most 35 characters) | +| Web → native | `voice.stop` | `requestId`; finish and allow a final result | +| Web → native | `voice.cancel` | `requestId`; abort and discard results | +| Native → web | `voice.event` | `requestId`, `state`, optional `text` or `error` | + +Start, stop and cancel use `send` without a correlated reply; the probe uses `request`. +Events use subscription broadcasts with strict schemas. +States are `listening`, `partial`, `final`, `ended` and `error`. +Only `partial` and `final` may carry text, bounded to 512 characters. +Error codes are `not-allowed`, `language-not-supported`, `no-speech`, `audio-capture`, `busy`, `unavailable`, `aborted` and `generic`. +`NativeVoiceInput` creates a new request ID per utterance, unsubscribes on cancellation and rejects foreign or late events. + +## Privacy + +Audio is processed on the device by the OS recogniser. +OpenMapX does not transmit or store audio; `recordingOptions.persist` is false. +Transcripts are not logged. +The transcript becomes ordinary search text and follows the normal search-request and search-history rules. +The compliance registry separates this local audio practice (`voice-search-audio`) from subsequent search data flows. + +## Open verification + +Source and unit checks do not establish installed-device behavior. +Before qualifying a candidate, test airplane-mode recognition with installed models and German and English place names. +Cover missing models, download completion, permission grant, denial, permanent denial and revocation. +Check microphone release after stop, cancellation, backgrounding, screen off, page replacement and teardown. +Verify guidance hold/release, stale-cue dropping and WebView capture denial on the candidate binary. +Measure accuracy and latency separately on a consented recording set; no such measurement is claimed here. diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index 39e314369..79b92f090 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -61,13 +61,25 @@ The canonical name remains visible so you can check what a code resolved to. ### Voice search -Use the microphone button where the browser supports speech recognition. Grant -microphone permission, speak the query, and OpenMapX submits the final transcript -through ordinary search. The button is hidden on unsupported browsers. - -Audio processing belongs to the browser and operating system. It may use the -browser vendor's speech service. Check that browser's privacy settings before -speaking sensitive information. +On the website, use the microphone button where the browser supports speech +recognition. Grant microphone permission and speak the query. Interim text fills +the field, and the final transcript submits through ordinary search. The button +is hidden on unsupported browsers. Browser recognition may send audio to the +browser vendor's speech service; check its privacy settings before speaking. + +The installed app uses the same button and warning snackbar with native +on-device recognition on Android 13+ when the service is available. The first +tap requests Android microphone permission. If denied, the warning points to +Android settings. There is no additional settings button or custom dialog. +If the language model is missing, the warning explains this and Android's own +model download is requested. Downloads need a network connection; recognition +does not. iOS and older Android versions do not offer app voice search. + +Tap again to finish an utterance. Leaving the page or backgrounding the app +cancels listening. OpenMapX does not transmit or store app microphone audio. +The transcript follows the usual search-request and search-history rules. +See the [native voice-input design](../developer/voice-input.md) for limits and +device qualification still required. ### Category and POI search diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 8843b8d48..87bf627b7 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -214,6 +214,7 @@ const sidebars: SidebarsConfig = { items: [ 'developer/mobile-architecture', 'developer/android-auto', + 'developer/voice-input', 'developer/mobile-feasibility', 'developer/mobile-release', 'developer/mobile-store-operations', diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts index a7c381e8b..8c2e5a38a 100644 --- a/packages/core/src/navigation/index.ts +++ b/packages/core/src/navigation/index.ts @@ -146,6 +146,8 @@ export { type TransitNavigationStartPackage, transitStartPackageSchema, transitStopAreaSchema, + type VoiceEvent, + voiceEventSchema, WEB_TO_NATIVE_TYPES, type WebToNativeMessage, webToNativeSchema, diff --git a/packages/core/src/navigation/mobileProtocol.test.ts b/packages/core/src/navigation/mobileProtocol.test.ts index 8b3d52e0b..17f9d0204 100644 --- a/packages/core/src/navigation/mobileProtocol.test.ts +++ b/packages/core/src/navigation/mobileProtocol.test.ts @@ -84,6 +84,123 @@ const parse = (value: unknown, options = {}) => const errorCodeOf = (result: ReturnType) => result.ok ? undefined : result.error.code; +describe("voice contract", () => { + it.each([ + ["voice.probe", {}], + ["voice.availability", { forMessageId: "probe-1", available: true }], + ["voice.start", { requestId: "r", locale: "de-DE" }], + ["voice.stop", { requestId: "r" }], + ["voice.cancel", { requestId: "r" }], + ...["listening", "partial", "final", "ended", "error"].map( + (state): [string, Record] => ["voice.event", { requestId: "r", state }], + ), + ])("round trips %s", (type, payload) => { + const input = message({ type, payload }); + expect(parse(input)).toEqual({ ok: true, message: input }); + expect(parse(message({ type, payload: { ...payload, extra: true } })).ok).toBe(false); + }); + + it("declares probe directions and requires a bounded correlation id and boolean answer", () => { + expect(WEB_TO_NATIVE_TYPES).toContain("voice.probe"); + expect(NATIVE_TO_WEB_TYPES).toContain("voice.availability"); + expect(NATIVE_TO_WEB_TYPES).not.toContain("voice.probe"); + expect(WEB_TO_NATIVE_TYPES).not.toContain("voice.availability"); + for (const payload of [ + { available: true }, + { forMessageId: "", available: true }, + { forMessageId: "r".repeat(129), available: true }, + { forMessageId: "r", available: "true" }, + ]) + expect(parse(message({ type: "voice.availability", payload })).ok).toBe(false); + }); + + it("bounds ids, locales and transcripts", () => { + for (const requestId of ["", "r".repeat(129)]) { + expect(parse(message({ type: "voice.stop", payload: { requestId } })).ok).toBe(false); + } + for (const locale of ["", "d", "de_DE", "de-", "abcd-DE", `de-${"a".repeat(33)}`]) { + expect(parse(message({ type: "voice.start", payload: { requestId: "r", locale } })).ok).toBe( + false, + ); + } + for (const state of ["partial", "final"]) { + expect( + parse( + message({ + type: "voice.event", + payload: { requestId: "r", state, text: "a".repeat(512) }, + }), + ).ok, + ).toBe(true); + expect( + parse( + message({ + type: "voice.event", + payload: { requestId: "r", state, text: "a".repeat(513) }, + }), + ).ok, + ).toBe(false); + } + for (const state of ["listening", "ended", "error"]) { + expect( + parse(message({ type: "voice.event", payload: { requestId: "r", state, text: "hi" } })).ok, + ).toBe(false); + } + expect( + parse( + message({ + type: "voice.event", + payload: { requestId: "r", state: "error", error: "network" }, + }), + ).ok, + ).toBe(false); + }); + it("accepts exact id and locale bounds and the closed error vocabulary", () => { + expect( + parse( + message({ type: "voice.start", payload: { requestId: "r", locale: "de-DE-u-nu-latn" } }), + ).ok, + ).toBe(true); + expect( + parse( + message({ + type: "voice.start", + payload: { + requestId: "r".repeat(128), + locale: "de-abcdefgh-abcdefgh-abcdefgh-abcde", + }, + }), + ).ok, + ).toBe(true); + expect( + parse( + message({ + type: "voice.start", + payload: { + requestId: "r", + locale: "de-abcdefgh-abcdefgh-abcdefgh-abcdef", + }, + }), + ).ok, + ).toBe(false); + for (const error of [ + "not-allowed", + "language-not-supported", + "no-speech", + "audio-capture", + "busy", + "unavailable", + "aborted", + "generic", + ]) { + expect( + parse(message({ type: "voice.event", payload: { requestId: "r", state: "error", error } })) + .ok, + ).toBe(true); + } + }); +}); + describe("negotiateMobileProtocol", () => { it.each([ [{ min: 3, max: 3 }, { min: 3, max: 3 }, 3], diff --git a/packages/core/src/navigation/mobileProtocol.ts b/packages/core/src/navigation/mobileProtocol.ts index f88e6e241..8315984e1 100644 --- a/packages/core/src/navigation/mobileProtocol.ts +++ b/packages/core/src/navigation/mobileProtocol.ts @@ -12,7 +12,7 @@ import { z } from "zod"; * fetch, no JavaScript to evaluate, no SQL, no file path, no arbitrary * notification or speech text. A same-origin XSS in the page can reach * whatever this protocol exposes, so it exposes only bounded navigation - * commands. + * and shell commands. * - **Bounds are checked before side effects.** Byte size first, then JSON, * then a strict schema, then aggregate route limits. A message that fails any * stage produces a typed error and changes nothing. @@ -78,6 +78,10 @@ export const WEB_TO_NATIVE_TYPES = [ "location.request", "settings.open", "auth.open", + "voice.probe", + "voice.start", + "voice.stop", + "voice.cancel", ] as const; export const NATIVE_TO_WEB_TYPES = [ @@ -94,6 +98,8 @@ export const NATIVE_TO_WEB_TYPES = [ "settings.result", "deep-link.open", "auth.result", + "voice.availability", + "voice.event", ] as const; export type WebToNativeType = (typeof WEB_TO_NATIVE_TYPES)[number]; @@ -391,6 +397,23 @@ export type CarDestinations = z.infer; export const webToNativeSchema = z.discriminatedUnion("type", [ envelope("destinations.update", carDestinationsSchema), + envelope("voice.probe", z.object({}).strict()), + envelope( + "voice.start", + z + .object({ + requestId: boundedId, + locale: z + .string() + .max(35) + // BCP 47 subtags are 1–8 alphanumerics; singletons ("u", "x") + // introduce extensions, as in a WebView's "de-DE-u-nu-latn". + .regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$/), + }) + .strict(), + ), + envelope("voice.stop", z.object({ requestId: boundedId }).strict()), + envelope("voice.cancel", z.object({ requestId: boundedId }).strict()), envelope( "web.hello", z @@ -491,7 +514,38 @@ export const webToNativeSchema = z.discriminatedUnion("type", [ ), ]); +export const voiceEventSchema = z + .object({ + requestId: boundedId, + state: z.enum(["listening", "partial", "final", "ended", "error"]), + text: z.string().max(512).optional(), + error: z + .enum([ + "not-allowed", + "language-not-supported", + "no-speech", + "audio-capture", + "busy", + "unavailable", + "aborted", + "generic", + ]) + .optional(), + }) + .strict() + .refine( + (event) => event.text === undefined || event.state === "partial" || event.state === "final", + { message: "Text requires a transcript state" }, + ); + +export type VoiceEvent = z.infer; + export const nativeToWebSchema = z.discriminatedUnion("type", [ + envelope( + "voice.availability", + z.object({ forMessageId: boundedId, available: z.boolean() }).strict(), + ), + envelope("voice.event", voiceEventSchema), envelope( "native.hello", z diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index b4f8de9e7..efd7dead2 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -362,6 +362,8 @@ "clearHistory": "Verlauf löschen", "voiceSearchAriaLabel": "Per Sprache suchen", "voiceErrorNotAllowed": "Mikrofonzugriff ist blockiert. Erlaube ihn in den Website-Einstellungen deines Browsers und versuche es erneut.", + "voiceErrorNotAllowedApp": "Erlaube den Mikrofonzugriff für OpenMapX in den Android-Einstellungen und versuche es erneut.", + "voiceErrorLanguageApp": "Die Offline-Spracherkennung für diese Sprache ist auf diesem Gerät nicht installiert. Der Download durch das System wurde gestartet.", "voiceErrorNoMicrophone": "Es wurde kein Mikrofon gefunden.", "voiceErrorNetwork": "Die Sprachsuche benötigt eine Internetverbindung.", "voiceErrorLanguage": "Die Sprachsuche ist für diese Sprache nicht verfügbar.", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index b435db334..1673999c4 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -362,6 +362,8 @@ "clearHistory": "Clear history", "voiceSearchAriaLabel": "Search by voice", "voiceErrorNotAllowed": "Microphone access is blocked. Allow it in your browser's site settings, then try again.", + "voiceErrorNotAllowedApp": "Allow microphone access for OpenMapX in Android settings, then try again.", + "voiceErrorLanguageApp": "Offline speech for this language isn't installed on this device. The system download was started.", "voiceErrorNoMicrophone": "No microphone was found.", "voiceErrorNetwork": "Voice search needs an internet connection.", "voiceErrorLanguage": "Voice search isn't available for this language.", diff --git a/patches/expo-speech-recognition@57.1.1.patch b/patches/expo-speech-recognition@57.1.1.patch new file mode 100644 index 000000000..ee5937c8f --- /dev/null +++ b/patches/expo-speech-recognition@57.1.1.patch @@ -0,0 +1,31 @@ +diff --git a/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt b/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt +index 4a0604ab8ffd5112ec0bb770be56b3bc62dfcc80..ab2864a256c0d1b740e1f51c9ab022e557e91c2d 100644 +--- a/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt ++++ b/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt +@@ -633,7 +633,7 @@ class ExpoSpeechService( + ), + ) + } +- log("onResults(), results: $resultsList") ++ log("onResults()") + + teardownAndEnd() + } +@@ -643,7 +643,7 @@ class ExpoSpeechService( + // Avoid sending result event if there was an empty result, or the first result is an empty string + val nonEmptyStrings = partialResultsList.filter { it["transcript"]?.toString()?.isNotEmpty() ?: false } + +- log("onPartialResults(), results: $nonEmptyStrings") ++ log("onPartialResults()") + if (nonEmptyStrings.isNotEmpty()) { + sendEvent("result", mapOf("results" to nonEmptyStrings, "isFinal" to false)) + } +@@ -685,7 +685,7 @@ class ExpoSpeechService( + ), + ) + } +- log("onSegmentResults(), transcriptions: $resultsList") ++ log("onSegmentResults()") + + // If the user opted to stop + if (recognitionState == RecognitionState.STOPPING) { diff --git a/patches/react-native-webview@13.16.1.patch b/patches/react-native-webview@13.16.1.patch new file mode 100644 index 000000000..16cb13c57 --- /dev/null +++ b/patches/react-native-webview@13.16.1.patch @@ -0,0 +1,20 @@ +diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java +index 07f73fd00a083ebf5a03bffaa7bbadbd1f648505..0e50b1997e7775d2846081b7560e51868fa25ccb 100644 +--- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java ++++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java +@@ -145,6 +145,15 @@ public class RNCWebChromeClient extends WebChromeClient implements LifecycleEven + @Override + public void onPermissionRequest(final PermissionRequest request) { + ++ // OpenMapX: native voice input never authorizes WebView media capture. ++ for (String resource : request.getResources()) { ++ if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource) ++ || PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) { ++ request.deny(); ++ return; ++ } ++ } ++ + grantedPermissions = new ArrayList<>(); + + ArrayList requestedAndroidPermissions = new ArrayList<>(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f688ed907..b85e00747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,10 @@ overrides: better-auth>vue: '-' drizzle-orm>expo-sqlite: '-' +patchedDependencies: + expo-speech-recognition@57.1.1: 3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264 + react-native-webview@13.16.1: c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5 + importers: .: @@ -427,46 +431,49 @@ importers: version: 12.0.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) babel-preset-expo: specifier: ~57.0.13 - version: 57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1) + version: 57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1) expo: specifier: ~57.0.26 - version: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + version: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) expo-application: specifier: ~57.0.3 - version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) + version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) expo-build-properties: specifier: ~57.0.22 - version: 57.0.22(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) + version: 57.0.22(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) expo-constants: specifier: ~57.0.20 - version: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + version: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) expo-crypto: specifier: 57.0.3 - version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) + version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) expo-keep-awake: specifier: ~57.0.2 - version: 57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1) + version: 57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1) expo-location: specifier: ~57.0.20 - version: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) + version: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) expo-notifications: specifier: ~57.0.21 - version: 57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + version: 57.0.21(258a15991fb2f6b5cd5b08f93c378644) + expo-speech-recognition: + specifier: 57.1.1 + version: 57.1.1(patch_hash=3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) expo-splash-screen: specifier: ~57.0.9 - version: 57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) + version: 57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) expo-sqlite: specifier: ~57.0.3 - version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) expo-system-ui: specifier: ~57.0.4 - version: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + version: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) expo-task-manager: specifier: ~57.0.21 - version: 57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) + version: 57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) expo-web-browser: specifier: ~57.0.3 - version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) + version: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) react: specifier: 19.3.1 version: 19.3.1 @@ -475,7 +482,7 @@ importers: version: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) react-native-webview: specifier: 13.16.1 - version: 13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + version: 13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) zod: specifier: ^4.6.7 version: 4.6.7 @@ -515,7 +522,7 @@ importers: version: 30.5.2(@types/node@26.6.6)(babel-plugin-macros@3.1.0)(supports-color@8.1.1) jest-expo: specifier: ^57.0.5 - version: 57.0.5(c7ab9ac17891f20fff2f52f0e48d3385) + version: 57.0.5(35cafcd5568d70073f16c8b0222510e8) test-renderer: specifier: ^1.3.1 version: 1.3.1(@types/react@19.3.0)(react@19.3.1) @@ -8720,6 +8727,13 @@ packages: resolution: {integrity: sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==} engines: {node: '>=20.16.0'} + expo-speech-recognition@57.1.1: + resolution: {integrity: sha512-e1RalFEvc7yui9Jnf1NiEYHT0jSPeH4hbMTafj1yjvcKN7HhZ7VrQigGUhBDB9AnDNjtPugbyPuCIjwHBBOCyw==} + peerDependencies: + expo: '*' + react: '*' + react-native: 0.86.3 + expo-splash-screen@57.0.9: resolution: {integrity: sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA==} peerDependencies: @@ -13656,7 +13670,7 @@ snapshots: optionalDependencies: '@noble/hashes': 2.4.0 - '@expo/cli@57.0.27(973df613006c92c93696a1efb17770b5)': + '@expo/cli@57.0.27(772bdb24e39d3df923a6736a4df080eb)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 57.0.9(supports-color@8.1.1)(typescript@6.0.3) @@ -13666,16 +13680,16 @@ snapshots: '@expo/image-utils': 0.11.5(supports-color@8.1.1)(typescript@6.0.3) '@expo/inline-modules': 0.1.7(supports-color@8.1.1)(typescript@6.0.3) '@expo/json-file': 11.2.1 - '@expo/log-box': 57.0.4(89ba4b77157a417f78171df5c6603754) + '@expo/log-box': 57.0.4(a03445a702de6f0994d9fe998e13fee5) '@expo/metro': 56.0.2(supports-color@8.1.1) - '@expo/metro-config': 57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) + '@expo/metro-config': 57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) '@expo/metro-file-map': 57.0.3(supports-color@8.1.1) '@expo/osascript': 2.8.1 '@expo/package-manager': 1.14.2 '@expo/plist': 0.8.1 '@expo/prebuild-config': 57.0.16(supports-color@8.1.1)(typescript@6.0.3) '@expo/require-utils': 57.0.5(supports-color@8.1.1)(typescript@6.0.3) - '@expo/router-server': 57.0.11(8e67035cdcd881304a7dd050bd3d75bc) + '@expo/router-server': 57.0.11(ae87db08c1472b741ab6581a46da1390) '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.1(ws@8.22.0) @@ -13692,7 +13706,7 @@ snapshots: connect: 3.7.0(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) dnssd-advertise: 1.1.6 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) expo-server: 57.0.3 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -13788,9 +13802,9 @@ snapshots: react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - '@expo/dom-webview@57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)': + '@expo/dom-webview@57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)': dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) @@ -13866,16 +13880,16 @@ snapshots: - supports-color - typescript - '@expo/log-box@57.0.4(89ba4b77157a417f78171df5c6603754)': + '@expo/log-box@57.0.4(a03445a702de6f0994d9fe998e13fee5)': dependencies: - '@expo/dom-webview': 57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + '@expo/dom-webview': 57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) anser: 1.4.10 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) stacktrace-parser: 0.1.11 - '@expo/metro-config@57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3)': + '@expo/metro-config@57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7(supports-color@8.1.1) @@ -13901,7 +13915,7 @@ snapshots: postcss: 8.5.32 resolve-from: 5.0.0 optionalDependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -13991,12 +14005,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@57.0.11(8e67035cdcd881304a7dd050bd3d75bc)': + '@expo/router-server@57.0.11(ae87db08c1472b741ab6581a46da1390)': dependencies: debug: 4.4.3(supports-color@8.1.1) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) - expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) - expo-font: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + expo-font: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) expo-server: 57.0.3 react: 19.3.1 optionalDependencies: @@ -17246,7 +17260,7 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) - babel-preset-expo@57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1): + babel-preset-expo@57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1): dependencies: '@babel/generator': 7.29.8 '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) @@ -17293,7 +17307,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.10 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -18311,63 +18325,63 @@ snapshots: jest-mock: 30.5.2 jest-util: 30.5.1 - expo-application@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): + expo-application@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) - expo-asset@57.0.18(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3): + expo-asset@57.0.18(258a15991fb2f6b5cd5b08f93c378644): dependencies: '@expo/image-utils': 0.11.5(supports-color@8.1.1)(typescript@6.0.3) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) - expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - typescript - expo-build-properties@57.0.22(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): + expo-build-properties@57.0.22(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): dependencies: '@expo/schema-utils': 57.0.2 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.8.5 - expo-constants@57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1): + expo-constants@57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@expo/env': 2.4.3(supports-color@8.1.1) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - expo-crypto@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): + expo-crypto@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) expo-doctor@1.21.5: {} - expo-file-system@57.0.7(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): + expo-file-system@57.0.7(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - expo-font@57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): + expo-font@57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) fontfaceobserver: 2.3.0 react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - expo-keep-awake@57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1): + expo-keep-awake@57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react: 19.3.1 - expo-location@57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3): + expo-location@57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.11.5(supports-color@8.1.1)(typescript@6.0.3) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript @@ -18394,14 +18408,14 @@ snapshots: dependencies: react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - expo-notifications@57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3): + expo-notifications@57.0.21(258a15991fb2f6b5cd5b08f93c378644): dependencies: '@expo/image-utils': 0.11.5(supports-color@8.1.1)(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) - expo-application: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) - expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo-application: 57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3)) + expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) transitivePeerDependencies: @@ -18410,63 +18424,69 @@ snapshots: expo-server@57.0.3: {} - expo-splash-screen@57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3): + ? expo-speech-recognition@57.1.1(patch_hash=3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + : dependencies: + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + react: 19.3.1 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) + + expo-splash-screen@57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3): dependencies: '@expo/config-plugins': 57.0.9(supports-color@8.1.1)(typescript@6.0.3) '@expo/image-utils': 0.11.5(supports-color@8.1.1)(typescript@6.0.3) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) xml2js: 0.6.0 transitivePeerDependencies: - supports-color - typescript - expo-sqlite@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): + expo-sqlite@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): dependencies: await-lock: 2.2.2 - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react: 19.3.1 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - expo-system-ui@57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1): + expo-system-ui@57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@react-native/normalize-colors': 0.86.3 debug: 4.4.3(supports-color@8.1.1) - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - expo-task-manager@57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): + expo-task-manager@57.0.21(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) unimodules-app-loader: 57.0.2 - expo-web-browser@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): + expo-web-browser@57.0.3(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)): dependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1) - expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3): + expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.10 - '@expo/cli': 57.0.27(973df613006c92c93696a1efb17770b5) + '@expo/cli': 57.0.27(772bdb24e39d3df923a6736a4df080eb) '@expo/config': 57.0.9(supports-color@8.1.1)(typescript@6.0.3) '@expo/config-plugins': 57.0.9(supports-color@8.1.1)(typescript@6.0.3) '@expo/devtools': 57.0.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) - '@expo/dom-webview': 57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + '@expo/dom-webview': 57.0.1(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) '@expo/fingerprint': 0.20.13(supports-color@8.1.1) '@expo/local-build-cache-provider': 57.0.8(supports-color@8.1.1)(typescript@6.0.3) - '@expo/log-box': 57.0.4(89ba4b77157a417f78171df5c6603754) + '@expo/log-box': 57.0.4(a03445a702de6f0994d9fe998e13fee5) '@expo/metro': 56.0.2(supports-color@8.1.1) - '@expo/metro-config': 57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) + '@expo/metro-config': 57.0.12(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) '@ungap/structured-clone': 1.4.0 - babel-preset-expo: 57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1) - expo-asset: 57.0.18(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) - expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) - expo-file-system: 57.0.7(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) - expo-font: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) - expo-keep-awake: 57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1) + babel-preset-expo: 57.0.13(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.10)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-refresh@0.14.2)(supports-color@8.1.1) + expo-asset: 57.0.18(258a15991fb2f6b5cd5b08f93c378644) + expo-constants: 57.0.20(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(supports-color@8.1.1) + expo-file-system: 57.0.7(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1)) + expo-font: 57.0.4(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + expo-keep-awake: 57.0.2(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react@19.3.1) expo-modules-autolinking: 57.0.13(supports-color@8.1.1)(typescript@6.0.3) expo-modules-core: 57.0.20(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) pretty-format: 29.7.0 @@ -18476,7 +18496,7 @@ snapshots: whatwg-url-minimum: 0.1.2 optionalDependencies: react-dom: 19.3.1(react@19.3.1) - react-native-webview: 13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + react-native-webview: 13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -19389,7 +19409,7 @@ snapshots: jest-util: 30.5.1 jest-validate: 30.5.1 - jest-expo@57.0.5(c7ab9ac17891f20fff2f52f0e48d3385): + jest-expo@57.0.5(35cafcd5568d70073f16c8b0222510e8): dependencies: '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0(supports-color@8.1.1) @@ -19406,7 +19426,7 @@ snapshots: server-only: 0.0.1 stacktrace-js: 2.0.2 optionalDependencies: - expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) + expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -21230,7 +21250,7 @@ snapshots: react-is@19.3.1: {} - react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): + react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7fd5e7c89..ae216cab2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -182,3 +182,6 @@ peerDependencyRules: # React 19 and is covered by the web build/tests. allowedVersions: react: "^19" +patchedDependencies: + expo-speech-recognition@57.1.1: patches/expo-speech-recognition@57.1.1.patch + react-native-webview@13.16.1: patches/react-native-webview@13.16.1.patch diff --git a/services/data-manager/Dockerfile b/services/data-manager/Dockerfile index bd7fb246e..92788152b 100644 --- a/services/data-manager/Dockerfile +++ b/services/data-manager/Dockerfile @@ -35,6 +35,8 @@ WORKDIR /app ENV HUSKY=0 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# pnpm checks every patched dependency's file hash, installed here or not. +COPY patches/ patches/ COPY services/data-manager/package.json services/data-manager/ COPY packages/db-schema/package.json packages/db-schema/ COPY packages/hardlinks/package.json packages/hardlinks/ From 2f148dbe6956302be459cebbc03b6d9d012f35a6 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 15:06:02 +0100 Subject: [PATCH 2/5] fix(mobile): start voice input only after a touch the shell observed 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 --- apps/mobile/src/App.test.tsx | 35 ++++++++++++++- apps/mobile/src/App.tsx | 46 ++++++++++++++++++-- apps/mobile/src/voice/userIntentGate.test.ts | 37 ++++++++++++++++ apps/mobile/src/voice/userIntentGate.ts | 38 ++++++++++++++++ docs/docs/developer/voice-input.md | 7 +++ 5 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/voice/userIntentGate.test.ts create mode 100644 apps/mobile/src/voice/userIntentGate.ts diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 1011abca1..56f70c1a9 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -407,8 +407,9 @@ describe("App bridge channel", () => { }); }); + let shellView: Awaited>; const hello = async () => { - await renderApp(); + shellView = await renderApp(); const nonce = nonceIn(bootstrap()); await emitAsync(mockWebViewProps.onLoadStart); await post(nonce, { @@ -472,6 +473,7 @@ describe("App bridge channel", () => { .mockImplementation(() => {}); const nonce = await hello(); mockNavigation.coordinator.dispatch.mockImplementation(() => new Promise(() => {})); + await fireEvent(shellView.getByTestId("product-shell"), "touchStart"); await post(nonce, { messageId: "voice-1", type: "voice.start", @@ -485,6 +487,37 @@ describe("App bridge channel", () => { expect(mockNavigation.coordinator.dispatch).not.toHaveBeenCalled(); }); + it("refuses a voice start that no observed touch preceded, and admits one per touch", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + const start = jest.spyOn(VoiceInputController.prototype, "start").mockResolvedValue(); + const nonce = await hello(); + await post(nonce, { + messageId: "voice-untouched", + type: "voice.start", + payload: { requestId: "r0", locale: "en-US" }, + }); + expect(start).not.toHaveBeenCalled(); + expect(decodedMessages()).toContainEqual( + expect.objectContaining({ + type: "voice.event", + payload: { requestId: "r0", state: "error", error: "generic" }, + }), + ); + await fireEvent(shellView.getByTestId("product-shell"), "touchStart"); + await post(nonce, { + messageId: "voice-touched", + type: "voice.start", + payload: { requestId: "r1", locale: "en-US" }, + }); + await post(nonce, { + messageId: "voice-again", + type: "voice.start", + payload: { requestId: "r2", locale: "en-US" }, + }); + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith("r1", "en-US"); + }); + it("hands every page command to the navigation coordinator", async () => { mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); const nonce = await hello(); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 37c8b66ed..0131046ec 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -9,7 +9,15 @@ import { useRef, useState, } from "react"; -import { Alert, Linking, Platform, StatusBar, StyleSheet, View } from "react-native"; +import { + AccessibilityInfo, + Alert, + Linking, + Platform, + StatusBar, + StyleSheet, + View, +} from "react-native"; import { WebView } from "react-native-webview"; import { getNavigationAudio } from "./audio/navigationAudioModule"; import { createShellBridge, type ShellBridge } from "./bridge/createShellBridge"; @@ -38,6 +46,7 @@ import { deviceMobileLocale } from "./shell/shellCopy"; import { INITIAL_SHELL, shellReducer } from "./shell/shellReducer"; import { answerCommand, createShellServices } from "./shell/shellServices"; import { useHardwareBack } from "./shell/useHardwareBack"; +import { createUserIntentGate } from "./voice/userIntentGate"; import { VoiceInputController } from "./voice/VoiceInputController"; /** @@ -89,6 +98,22 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement const connectivity = useConnectivity(); const webViewRef = useRef(null); + const [voiceIntent] = useState(() => createUserIntentGate()); + useEffect(() => { + let mounted = true; + void AccessibilityInfo.isScreenReaderEnabled() + .then((enabled) => { + if (mounted) voiceIntent.setScreenReader(enabled); + }) + .catch(() => undefined); + const subscription = AccessibilityInfo.addEventListener("screenReaderChanged", (enabled) => + voiceIntent.setScreenReader(enabled), + ); + return () => { + mounted = false; + subscription.remove(); + }; + }, [voiceIntent]); const handleNavigationStateChange = useHardwareBack(webViewRef); useEffect(() => { webViewRef.current?.injectJavaScript(buildWebVisibilityScript(visibility)); @@ -145,6 +170,16 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement return; } if (message.type === "voice.start") { + // The microphone starts only after a touch the shell itself saw + // (see userIntentGate), never on the page's word alone. + if (!voiceIntent.admit()) { + composed.bridge.send("voice.event", { + requestId: message.payload.requestId, + state: "error", + error: "generic", + }); + return; + } void voice.start(message.payload.requestId, message.payload.locale); return; } @@ -181,7 +216,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement }).catch(() => null); } return { shell: composed, navigation, voice }; - }, [config, locale, prompter, services, setNavigating]); + }, [config, locale, prompter, services, setNavigating, voiceIntent]); useEffect(() => { if (visibility !== "active") voice.suspend(); }, [visibility, voice]); @@ -401,7 +436,12 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement ); return ( - + { + let clock = 0; + const gate = () => createUserIntentGate(() => clock); + beforeEach(() => { + clock = 1_000_000; + }); + + it("refuses a start no touch preceded", () => { + expect(gate().admit()).toBe(false); + }); + + it("admits one start per touch inside the window", () => { + const g = gate(); + g.touched(); + clock += USER_INTENT_WINDOW_MS; + expect(g.admit()).toBe(true); + expect(g.admit()).toBe(false); + }); + + it("refuses a start after the window", () => { + const g = gate(); + g.touched(); + clock += USER_INTENT_WINDOW_MS + 1; + expect(g.admit()).toBe(false); + }); + + it("waives the touch while a screen reader is on, and only then", () => { + const g = gate(); + g.setScreenReader(true); + expect(g.admit()).toBe(true); + expect(g.admit()).toBe(true); + g.setScreenReader(false); + expect(g.admit()).toBe(false); + }); +}); diff --git a/apps/mobile/src/voice/userIntentGate.ts b/apps/mobile/src/voice/userIntentGate.ts new file mode 100644 index 000000000..0c34cba0e --- /dev/null +++ b/apps/mobile/src/voice/userIntentGate.ts @@ -0,0 +1,38 @@ +/** + * Whether a `voice.start` from the page follows something the person did. + * + * The page is trusted first-party code, but a script injected into it could + * otherwise start the microphone in a loop and read the transcripts. So the + * shell starts listening only within a short window after a touch it observed + * itself on the screen, and each touch admits one start. With a screen reader + * on, activation arrives as an accessibility action rather than a touch, so the + * touch requirement is waived there instead of locking those users out. + */ +export const USER_INTENT_WINDOW_MS = 3000; + +export interface UserIntentGate { + /** A touch began somewhere in the shell's view tree. */ + touched(): void; + setScreenReader(enabled: boolean): void; + /** True once per touch inside the window, or while a screen reader is on. */ + admit(): boolean; +} + +export function createUserIntentGate(now: () => number = Date.now): UserIntentGate { + let touchedAt: number | null = null; + let screenReader = false; + return { + touched() { + touchedAt = now(); + }, + setScreenReader(enabled) { + screenReader = enabled; + }, + admit() { + if (screenReader) return true; + const at = touchedAt; + touchedAt = null; + return at !== null && now() - at <= USER_INTENT_WINDOW_MS; + }, + }; +} diff --git a/docs/docs/developer/voice-input.md b/docs/docs/developer/voice-input.md index 5abe2a7b4..c29e52af1 100644 --- a/docs/docs/developer/voice-input.md +++ b/docs/docs/developer/voice-input.md @@ -132,6 +132,13 @@ Transcripts are not logged. The transcript becomes ordinary search text and follows the normal search-request and search-history rules. The compliance registry separates this local audio practice (`voice-search-audio`) from subsequent search data flows. +The page cannot start the microphone on its own. +The shell accepts `voice.start` only within three seconds of a touch it observed itself on the screen, and each touch admits one start. +A script injected into the page therefore cannot listen in a loop. +With a screen reader on, activation arrives as an accessibility action rather than a touch, so the touch requirement is waived there. +A refused start answers `voice.event` with `error: "generic"` and records nothing. +Android's own microphone indicator shows whenever the recogniser holds the microphone, and the page cannot hide it. + ## Open verification Source and unit checks do not establish installed-device behavior. From cac1315177c52aa468af24a99085d6fb31be1b73 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 15:24:33 +0100 Subject: [PATCH 3/5] docs(legal): date the privacy policy update for on-device voice search Refs #405 --- apps/web/src/app/(legal)/privacy/content.de.tsx | 2 +- apps/web/src/app/(legal)/privacy/content.en.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(legal)/privacy/content.de.tsx b/apps/web/src/app/(legal)/privacy/content.de.tsx index b69cd1222..94d75b39d 100644 --- a/apps/web/src/app/(legal)/privacy/content.de.tsx +++ b/apps/web/src/app/(legal)/privacy/content.de.tsx @@ -67,7 +67,7 @@ export default function PrivacyContentDe({ mb: 4, }} > - Zuletzt aktualisiert: 28. Oktober 2026 + Zuletzt aktualisiert: 4. November 2026
diff --git a/apps/web/src/app/(legal)/privacy/content.en.tsx b/apps/web/src/app/(legal)/privacy/content.en.tsx index 0479cfce2..d89559248 100644 --- a/apps/web/src/app/(legal)/privacy/content.en.tsx +++ b/apps/web/src/app/(legal)/privacy/content.en.tsx @@ -67,7 +67,7 @@ export default function PrivacyContent({ mb: 4, }} > - Last updated: October 28, 2026 + Last updated: November 4, 2026
From 92b3d0941d71e10c75330fd826610bae344162ec Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 18:36:17 +0100 Subject: [PATCH 4/5] feat(mobile): search by voice on iPhone with on-device recognition only 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 --- .../compliance/apple-privacy-worksheet.md | 6 +- apps/mobile/compliance/data-practices.json | 6 +- .../google-data-safety-worksheet.md | 6 +- apps/mobile/config/locales/de.json | 2 +- apps/mobile/config/locales/en.json | 2 +- apps/mobile/config/nativeCopy.ts | 4 +- .../scripts/assert-no-community-runtime.mts | 13 ++ .../mobile/scripts/voiceCapturePolicy.test.ts | 31 ++++ apps/mobile/scripts/voiceCapturePolicy.ts | 27 +++ .../src/voice/VoiceInputController.test.ts | 168 +++++++++++++++++- apps/mobile/src/voice/VoiceInputController.ts | 132 ++++++++++---- apps/mobile/src/voice/audioArbiter.test.ts | 4 +- apps/mobile/src/voice/audioArbiter.ts | 37 ++-- .../src/app/(legal)/privacy/content.de.tsx | 21 +-- .../src/app/(legal)/privacy/content.en.tsx | 16 +- .../search/VoiceSearchButton.test.tsx | 27 +-- .../components/search/VoiceSearchButton.tsx | 12 +- docs/docs/developer/mobile-architecture.md | 22 +-- docs/docs/developer/mobile-feasibility.md | 4 +- docs/docs/developer/mobile-release.md | 4 +- .../docs/developer/mobile-store-operations.md | 9 +- docs/docs/developer/voice-input.md | 77 +++++++- docs/docs/features/search.md | 10 +- packages/i18n/locales/de.json | 2 + packages/i18n/locales/en.json | 2 + patches/expo-speech-recognition@57.1.1.patch | 147 +++++++++++++++ pnpm-lock.yaml | 6 +- 27 files changed, 664 insertions(+), 133 deletions(-) create mode 100644 apps/mobile/scripts/voiceCapturePolicy.test.ts create mode 100644 apps/mobile/scripts/voiceCapturePolicy.ts diff --git a/apps/mobile/compliance/apple-privacy-worksheet.md b/apps/mobile/compliance/apple-privacy-worksheet.md index 9f1ab169c..335bbdfc2 100644 --- a/apps/mobile/compliance/apple-privacy-worksheet.md +++ b/apps/mobile/compliance/apple-privacy-worksheet.md @@ -38,17 +38,17 @@ below remain pending review; omitted rows do not establish an exemption. ## On-device voice-search audio -Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted. +Convert one user-initiated utterance into search text using the OS on-device recogniser on iOS and Android 13+; no network recogniser fallback is permitted. Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules. -User control: Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings. +User control: Tap the microphone to start and grant microphone permission and, on iOS, speech-recognition authorization; tap again to stop. Backgrounding or leaving the page cancels. Revoke permissions in platform settings. Proposed collection: Apple No; Google No. Proposed Google sharing: No. Purpose: App functionality. -Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. +Design classification pending candidate device/privacy qualification. Android below API 33 is disabled. iOS requires locale-specific offline support and the fail-closed package patch; no app-triggered iOS model download is used. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. These are design classifications, pending candidate device/privacy qualification. diff --git a/apps/mobile/compliance/data-practices.json b/apps/mobile/compliance/data-practices.json index d23dc1c6b..828354748 100644 --- a/apps/mobile/compliance/data-practices.json +++ b/apps/mobile/compliance/data-practices.json @@ -506,14 +506,14 @@ "dataType": "Microphone audio for user-initiated on-device voice search", "source": "device-sensor", "leavesDevice": false, - "purpose": "Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted.", + "purpose": "Convert one user-initiated utterance into search text using the OS on-device recogniser on iOS and Android 13+; no network recogniser fallback is permitted.", "linkedToAccount": false, "retention": { "kind": "ephemeral", "note": "Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules." }, "shared": "none", - "userControl": "Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings.", + "userControl": "Tap the microphone to start and grant microphone permission and, on iOS, speech-recognition authorization; tap again to stop. Backgrounding or leaving the page cancels. Revoke permissions in platform settings.", "appleLabel": { "collected": false, "category": "Audio Data", @@ -530,7 +530,7 @@ "purposes": ["App functionality"] }, "legalSection": "Voice Search (Microphone)", - "notes": "Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it." + "notes": "Design classification pending candidate device/privacy qualification. Android below API 33 is disabled. iOS requires locale-specific offline support and the fail-closed package patch; no app-triggered iOS model download is used. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it." } ] } diff --git a/apps/mobile/compliance/google-data-safety-worksheet.md b/apps/mobile/compliance/google-data-safety-worksheet.md index 5c307c8f7..aa3612056 100644 --- a/apps/mobile/compliance/google-data-safety-worksheet.md +++ b/apps/mobile/compliance/google-data-safety-worksheet.md @@ -36,17 +36,17 @@ also be reviewed; their absence does not establish that data is not collected. ## On-device voice-search audio -Convert one user-initiated utterance into search text using the OS on-device recogniser on Android 13+; no network recogniser fallback is permitted. +Convert one user-initiated utterance into search text using the OS on-device recogniser on iOS and Android 13+; no network recogniser fallback is permitted. Audio is processed during the utterance only and is not retained by OpenMapX. Recording persistence is disabled. Transcripts are not logged by voice input; resulting search text follows normal search-request and search-history rules. -User control: Tap the microphone to start and grant Android microphone permission; tap again to stop. Backgrounding or leaving the page cancels. Revoke microphone permission in Android settings. +User control: Tap the microphone to start and grant microphone permission and, on iOS, speech-recognition authorization; tap again to stop. Backgrounding or leaving the page cancels. Revoke permissions in platform settings. Proposed collection: Apple No; Google No. Proposed Google sharing: No. Purpose: App functionality. -Design classification pending candidate device/privacy qualification. iOS and Android below API 33 are disabled. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. +Design classification pending candidate device/privacy qualification. Android below API 33 is disabled. iOS requires locale-specific offline support and the fail-closed package patch; no app-triggered iOS model download is used. Model downloads require a network; recognition does not. Only transcript text crosses the bridge, then ordinary search may transmit it. These are design classifications, pending candidate device/privacy qualification. diff --git a/apps/mobile/config/locales/de.json b/apps/mobile/config/locales/de.json index ae8ec4281..975f1b462 100644 --- a/apps/mobile/config/locales/de.json +++ b/apps/mobile/config/locales/de.json @@ -2,5 +2,5 @@ "NSLocationWhenInUseUsageDescription": "OpenMapX verwendet deinen Standort, um deine Position auf der Karte anzuzeigen und dich entlang einer von dir gestarteten Route zu führen.", "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX verwendet deinen Standort im Hintergrund, damit eine von dir gestartete Navigation dich weiter führt, während der Bildschirm gesperrt ist oder die App nicht verwendet wird. Die Standorterfassung endet, sobald du die Navigation beendest.", "NSMicrophoneUsageDescription": "OpenMapX verwendet das Mikrofon für die Sprachsuche, wenn du auf die Mikrofontaste tippst.", - "NSSpeechRecognitionUsageDescription": "OpenMapX bindet die Spracherkennung für die Sprachsuche ein. Die Sprachsuche ist derzeit nur unter Android verfügbar." + "NSSpeechRecognitionUsageDescription": "OpenMapX verwendet die Spracherkennung auf dem Gerät, um deine Stimme beim Tippen auf die Mikrofontaste in Suchtext umzuwandeln." } diff --git a/apps/mobile/config/locales/en.json b/apps/mobile/config/locales/en.json index e66a23490..bbf8f66ba 100644 --- a/apps/mobile/config/locales/en.json +++ b/apps/mobile/config/locales/en.json @@ -2,5 +2,5 @@ "NSLocationWhenInUseUsageDescription": "OpenMapX uses your location to show where you are on the map and to guide you along a route you start.", "NSLocationAlwaysAndWhenInUseUsageDescription": "OpenMapX uses your location in the background so navigation you have started keeps guiding you while the screen is locked or the app is not in use. Tracking stops when you end navigation.", "NSMicrophoneUsageDescription": "OpenMapX uses the microphone for voice search when you tap the microphone button.", - "NSSpeechRecognitionUsageDescription": "OpenMapX links speech recognition for voice search. Voice search is currently available only on Android." + "NSSpeechRecognitionUsageDescription": "OpenMapX uses on-device speech recognition to turn your voice into search text when you tap the microphone." } diff --git a/apps/mobile/config/nativeCopy.ts b/apps/mobile/config/nativeCopy.ts index 9ad08a83a..925eda1ec 100644 --- a/apps/mobile/config/nativeCopy.ts +++ b/apps/mobile/config/nativeCopy.ts @@ -35,7 +35,7 @@ export const IOS_USAGE_DESCRIPTIONS: Record NSMicrophoneUsageDescription: "OpenMapX uses the microphone for voice search when you tap the microphone button.", NSSpeechRecognitionUsageDescription: - "OpenMapX links speech recognition for voice search. Voice search is currently available only on Android.", + "OpenMapX uses on-device speech recognition to turn your voice into search text when you tap the microphone.", NSLocationWhenInUseUsageDescription: "OpenMapX uses your location to show where you are on the map and to guide you along a route you start.", @@ -46,7 +46,7 @@ export const IOS_USAGE_DESCRIPTIONS: Record NSMicrophoneUsageDescription: "OpenMapX verwendet das Mikrofon für die Sprachsuche, wenn du auf die Mikrofontaste tippst.", NSSpeechRecognitionUsageDescription: - "OpenMapX bindet die Spracherkennung für die Sprachsuche ein. Die Sprachsuche ist derzeit nur unter Android verfügbar.", + "OpenMapX verwendet die Spracherkennung auf dem Gerät, um deine Stimme beim Tippen auf die Mikrofontaste in Suchtext umzuwandeln.", NSLocationWhenInUseUsageDescription: "OpenMapX verwendet deinen Standort, um deine Position auf der Karte anzuzeigen und dich entlang einer von dir gestarteten Route zu führen.", diff --git a/apps/mobile/scripts/assert-no-community-runtime.mts b/apps/mobile/scripts/assert-no-community-runtime.mts index e6291e07f..d8c71f9ba 100644 --- a/apps/mobile/scripts/assert-no-community-runtime.mts +++ b/apps/mobile/scripts/assert-no-community-runtime.mts @@ -17,6 +17,7 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; +import { hasIosOnDeviceGuard } from "./voiceCapturePolicy.ts"; import { hasWebViewCaptureDenial } from "./webViewCapturePolicy.ts"; const repoRoot = resolve(import.meta.dirname, "../../.."); @@ -168,6 +169,18 @@ if (/log\([^\n]*(?:\$resultsList|\$nonEmptyStrings)/.test(speechCode)) { findings.push({ file: speechSource, problem: "installed speech module logs transcripts" }); } +const speechRoot = dirname(require.resolve("expo-speech-recognition/package.json")); +if ( + !hasIosOnDeviceGuard( + readFileSync(join(speechRoot, "ios/ExpoSpeechRecognitionModule.swift"), "utf8"), + readFileSync(join(speechRoot, "ios/ExpoSpeechRecognizer.swift"), "utf8"), + ) +) + findings.push({ + file: speechRoot, + problem: "installed iOS on-device fail-closed patch is missing", + }); + if (findings.length > 0) { console.error("[assert-no-community-runtime] the store distribution boundary is not intact:\n"); for (const finding of findings) console.error(` ${finding.file}: ${finding.problem}`); diff --git a/apps/mobile/scripts/voiceCapturePolicy.test.ts b/apps/mobile/scripts/voiceCapturePolicy.test.ts new file mode 100644 index 000000000..3e3ec3812 --- /dev/null +++ b/apps/mobile/scripts/voiceCapturePolicy.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { hasIosOnDeviceGuard } from "./voiceCapturePolicy"; + +const root = dirname( + createRequire(import.meta.url).resolve("expo-speech-recognition/package.json"), +); +const module = readFileSync(join(root, "ios/ExpoSpeechRecognitionModule.swift"), "utf8"); +const recognizer = readFileSync(join(root, "ios/ExpoSpeechRecognizer.swift"), "utf8"); +it("installed iOS recognizer fails closed before capture", () => + expect(hasIosOnDeviceGuard(module, recognizer)).toBe(true)); +it.each([ + "recognizer.isAvailable", + "recognizer.supportsOnDeviceRecognition", + 'error: "language-not-supported"', + 'sendErrorAndStop(error: "language-not-supported", message: "On-device recognition is unavailable for this locale.")\n return', + "(locale: String?)", + "case .nilRecognizer, .recognizerIsUnavailable:", +])("rejects module regression %s", (fragment) => { + expect(hasIosOnDeviceGuard(module.replace(fragment, "BROKEN"), recognizer)).toBe(false); +}); +it.each([ + "recognizer.isAvailable", + "recognizer.supportsOnDeviceRecognition", + "errorHandler(RecognizerError.nilRecognizer)", + "request.requiresOnDeviceRecognition = options.requiresOnDeviceRecognition", +])("rejects recognizer regression %s", (fragment) => { + expect(hasIosOnDeviceGuard(module, recognizer.replace(fragment, "BROKEN"))).toBe(false); +}); diff --git a/apps/mobile/scripts/voiceCapturePolicy.ts b/apps/mobile/scripts/voiceCapturePolicy.ts new file mode 100644 index 000000000..28e238eed --- /dev/null +++ b/apps/mobile/scripts/voiceCapturePolicy.ts @@ -0,0 +1,27 @@ +/** Structural release gate for the pinned iOS recognizer's fail-closed path. */ +export function hasIosOnDeviceGuard(module: string, recognizer: string): boolean { + const start = module.slice( + module.indexOf('Function("start")'), + module.indexOf('Function("setCategoryIOS")'), + ); + const capture = recognizer.slice( + recognizer.indexOf("private func startRecognizer("), + recognizer.indexOf("private func startRecognitionTask("), + ); + return ( + /if options\.requiresOnDeviceRecognition\s*\{\s*guard let recognizer = SFSpeechRecognizer\(locale: Locale\(identifier: options\.lang\)\),\s*recognizer\.isAvailable, recognizer\.supportsOnDeviceRecognition\s*else\s*\{\s*sendErrorAndStop\(error: "language-not-supported",[^\n]+\n\s*return/.test( + start, + ) && + start.includes("case .nilRecognizer, .recognizerIsUnavailable:") && + start.indexOf("if options.requiresOnDeviceRecognition") < + start.indexOf("try await ExpoSpeechRecognizer(") && + /if options\.requiresOnDeviceRecognition\s*\{\s*guard let recognizer, recognizer\.isAvailable, recognizer\.supportsOnDeviceRecognition else\s*\{\s*errorHandler\(RecognizerError.nilRecognizer\)\s*reset\(andEmitEnd: true\)\s*return/.test( + capture, + ) && + capture.indexOf("if options.requiresOnDeviceRecognition") < + capture.indexOf("Self.prepareRequest(") && + /request.requiresOnDeviceRecognition = options.requiresOnDeviceRecognition/.test(recognizer) && + !/if recognizer.supportsOnDeviceRecognition/.test(recognizer) && + module.includes('Function("supportsOnDeviceRecognition") { (locale: String?) -> Bool in') + ); +} diff --git a/apps/mobile/src/voice/VoiceInputController.test.ts b/apps/mobile/src/voice/VoiceInputController.test.ts index ecec1a316..e8c7dc78a 100644 --- a/apps/mobile/src/voice/VoiceInputController.test.ts +++ b/apps/mobile/src/voice/VoiceInputController.test.ts @@ -1,14 +1,29 @@ +import { createAudioArbiter } from "./audioArbiter"; import { mapVoiceError, selectInstalledLocale, VoiceInputController } from "./VoiceInputController"; -function fixture(platform = "android", api = 33, supported = true) { +function fixture( + platform = "android", + api = 33, + supported = true, + audioOverride?: ConstructorParameters[0]["audio"], +) { const handlers = new Map void>>(); const fire = (name: string, event: unknown = {}) => { for (const handler of [...(handlers.get(name) ?? [])]) handler(event as never); }; const module = { - supportsOnDeviceRecognition: jest.fn(() => supported), + supportsOnDeviceRecognition: jest.fn((_locale?: string) => supported), getMicrophonePermissionsAsync: jest.fn(async () => ({ granted: true, canAskAgain: true })), requestMicrophonePermissionsAsync: jest.fn(async () => ({ granted: true, canAskAgain: true })), + getSpeechRecognizerPermissionsAsync: jest.fn(async () => ({ + granted: true, + canAskAgain: true, + })), + requestSpeechRecognizerPermissionsAsync: jest.fn(async () => ({ + granted: true, + canAskAgain: true, + })), + setAudioSessionActiveIOS: jest.fn(), getSupportedLocales: jest.fn(async () => ({ installedLocales: ["de-DE", "en-US"], locales: ["fr-FR"], @@ -32,7 +47,7 @@ function fixture(platform = "android", api = 33, supported = true) { module: module as unknown as ConstructorParameters[0]["module"], platform, api, - audio, + audio: audioOverride ?? audio, emit, channel: () => channel, active: () => active, @@ -61,7 +76,7 @@ describe("native voice input", () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it.each([ - ["ios", 35, true], + ["ios", 35, false], ["android", 32, true], ["android", 35, false], ])("fails closed on %s API %s supported %s", (os, api, supported) => { @@ -98,8 +113,13 @@ describe("native voice input", () => { expect(f.emit.mock.calls[2][0].text).toHaveLength(512); expect(f.audio.release).toHaveBeenCalledTimes(1); }); - it.each([true, false])("denial canAskAgain=%s", async (canAskAgain) => { - const f = fixture(); + it.each([ + [true, "android"], + [false, "android"], + [true, "ios"], + [false, "ios"], + ] as const)("denial canAskAgain=%s platform=%s", async (canAskAgain, platform) => { + const f = fixture(platform); f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain }); f.module.requestMicrophonePermissionsAsync.mockResolvedValue({ granted: false, @@ -326,3 +346,139 @@ describe("native voice input", () => { expect(f.audio.release).toHaveBeenCalledTimes(1); }); }); + +describe("iOS on-device voice input", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + it("probes app languages individually and stays hidden when none works", () => { + const f = fixture("ios"); + f.module.supportsOnDeviceRecognition.mockImplementation( + (locale?: string) => locale === "en-GB", + ); + expect(f.controller.isAvailable()).toBe(true); + expect(f.module.supportsOnDeviceRecognition.mock.calls).toEqual([ + ["de-DE"], + ["en-US"], + ["en-GB"], + ]); + f.module.supportsOnDeviceRecognition.mockReturnValue(false); + expect(f.controller.isAvailable()).toBe(false); + }); + it("fails before recording or prompts for a missing language, never downloading", async () => { + const f = fixture("ios"); + f.module.supportsOnDeviceRecognition.mockImplementation( + (locale?: string) => locale === "de-DE", + ); + await f.controller.start("r", "en-AU"); + expect(f.emit).toHaveBeenLastCalledWith({ + requestId: "r", + state: "error", + error: "language-not-supported", + }); + expect(f.module.start).not.toHaveBeenCalled(); + expect(f.audio.acquire).not.toHaveBeenCalled(); + expect(f.module.getMicrophonePermissionsAsync).not.toHaveBeenCalled(); + expect(f.module.androidTriggerOfflineModelDownload).not.toHaveBeenCalled(); + }); + it.each(["en-AU", "en-GB"])("selects same-language offline locale for %s", async (locale) => { + const f = fixture("ios"); + f.module.supportsOnDeviceRecognition.mockImplementation((tag?: string) => tag === "en-GB"); + await f.controller.start("r", locale); + expect(f.module.start).toHaveBeenCalledWith( + expect.objectContaining({ + lang: "en-GB", + requiresOnDeviceRecognition: true, + iosCategory: { + category: "playAndRecord", + mode: "measurement", + categoryOptions: ["defaultToSpeaker", "allowBluetooth"], + }, + }), + ); + expect(f.module.getSupportedLocales).not.toHaveBeenCalled(); + expect(f.module.androidTriggerOfflineModelDownload).not.toHaveBeenCalled(); + f.fire("error", { error: "language-not-supported" }); + expect(f.emit).toHaveBeenLastCalledWith({ + requestId: "r", + state: "error", + error: "language-not-supported", + }); + f.fire("end"); + }); + it.each([true, false])("speech permission denial canAskAgain=%s", async (canAskAgain) => { + const f = fixture("ios"); + f.module.getSpeechRecognizerPermissionsAsync.mockResolvedValue({ granted: false, canAskAgain }); + f.module.requestSpeechRecognizerPermissionsAsync.mockResolvedValue({ + granted: false, + canAskAgain: false, + }); + await f.controller.start("r", "en-US"); + expect(f.module.requestSpeechRecognizerPermissionsAsync).toHaveBeenCalledTimes( + canAskAgain ? 1 : 0, + ); + expect(f.emit).toHaveBeenLastCalledWith({ + requestId: "r", + state: "error", + error: "not-allowed", + }); + expect(f.module.start).not.toHaveBeenCalled(); + }); + it("requests both permissions, survives inactive alerts and waits for active", async () => { + const f = fixture("ios"); + f.module.getMicrophonePermissionsAsync.mockResolvedValue({ granted: false, canAskAgain: true }); + f.module.getSpeechRecognizerPermissionsAsync.mockResolvedValue({ + granted: false, + canAskAgain: true, + }); + f.module.requestSpeechRecognizerPermissionsAsync.mockImplementation(async () => { + f.setActive(false); + f.controller.suspend(); + return { granted: true, canAskAgain: true }; + }); + const start = f.controller.start("r", "en-US"); + await jest.advanceTimersByTimeAsync(100); + expect(f.module.start).not.toHaveBeenCalled(); + f.setActive(true); + await jest.advanceTimersByTimeAsync(100); + await start; + expect(f.module.requestMicrophonePermissionsAsync).toHaveBeenCalledTimes(1); + expect(f.module.requestSpeechRecognizerPermissionsAsync).toHaveBeenCalledTimes(1); + expect(f.module.start).toHaveBeenCalledTimes(1); + f.fire("end"); + }); + it.each([false, true])( + "hands back before held guidance, except when speaking=%s", + async (speaking) => { + const order: string[] = []; + let guidanceSpeaking = false; + const arbiter = createAudioArbiter({ + speak: jest.fn(async () => { + order.push("cue"); + return "spoken" as const; + }), + stop: jest.fn(async () => {}), + getStatus: jest.fn(async () => ({ + initialized: true, + speaking: guidanceSpeaking, + localeAvailable: true, + lastResultCode: null, + })), + }); + const f = fixture("ios", 35, true, arbiter); + f.module.setAudioSessionActiveIOS.mockImplementation(() => { + order.push("deactivate"); + }); + await f.controller.start("r", "en-US"); + await arbiter.speak({ cueId: "held", text: "Turn left", locale: "en" }); + guidanceSpeaking = speaking; + order.push("end"); + f.fire("end"); + await jest.advanceTimersByTimeAsync(0); + expect(order).toEqual(speaking ? ["end", "cue"] : ["end", "deactivate", "cue"]); + if (!speaking) + expect(f.module.setAudioSessionActiveIOS).toHaveBeenCalledWith(false, { + notifyOthersOnDeactivation: true, + }); + }, + ); +}); diff --git a/apps/mobile/src/voice/VoiceInputController.ts b/apps/mobile/src/voice/VoiceInputController.ts index 0c778f2c0..754ec68a3 100644 --- a/apps/mobile/src/voice/VoiceInputController.ts +++ b/apps/mobile/src/voice/VoiceInputController.ts @@ -11,6 +11,9 @@ type SpeechModule = Pick< | "supportsOnDeviceRecognition" | "getMicrophonePermissionsAsync" | "requestMicrophonePermissionsAsync" + | "getSpeechRecognizerPermissionsAsync" + | "requestSpeechRecognizerPermissionsAsync" + | "setAudioSessionActiveIOS" | "getSupportedLocales" | "androidTriggerOfflineModelDownload" >; @@ -85,6 +88,10 @@ export class VoiceInputController { ) {} isAvailable(): boolean { try { + if (this.deps.platform === "ios") + return ["de-DE", "en-US", "en-GB"].some((locale) => + this.deps.module.supportsOnDeviceRecognition(locale), + ); return ( this.deps.platform === "android" && this.deps.api >= 33 && @@ -111,9 +118,8 @@ export class VoiceInputController { if (terminal ? owned : this.current(s)) this.deps.emit({ requestId: s.id, ...event }); } /** - * Android shows the permission prompt as another activity, so the app reads - * as backgrounded until the prompt closes and the activity resumes, which - * happens after the permission result arrives. Wait (bounded) for that. + * Android permission activities and iOS system alerts pause the app. The + * permission result can arrive before active state does; wait (bounded) for it. */ private async resumed(s: Session): Promise { for (let waited = 0; waited < RESUME_MS; waited += RESUME_POLL_MS) { @@ -133,12 +139,25 @@ export class VoiceInputController { for (const remove of s.remove) remove(); s.remove = []; if (this.session === s) this.session = null; + let released: Promise | undefined; if (this.lease === s) { - this.lease = null; - this.deps.audio.release(); + if (this.deps.platform === "ios") { + const release = this.deps.audio.release(() => { + this.deps.module.setAudioSessionActiveIOS(false, { notifyOthersOnDeactivation: true }); + }); + released = Promise.resolve(release) + .catch(() => {}) + .then(() => { + if (this.lease === s) this.lease = null; + }); + } else { + this.lease = null; + void this.deps.audio.release(); + } } if (confirmed || s.nativeStopped) { - s.drain(); + if (released) void released.then(s.drain); + else s.drain(); return; } // Wait for the recogniser's own end or error before any later session. @@ -148,7 +167,8 @@ export class VoiceInputController { const confirm = () => { for (const watcher of watchers) watcher.remove(); if (this.unconfirmed === s) this.unconfirmed = null; - s.drain(); + if (released) void released.then(s.drain); + else s.drain(); }; watchers.push(on("end", confirm), on("error", confirm)); } @@ -249,44 +269,73 @@ export class VoiceInputController { this.fail(s, "busy"); return; } - if (!this.isAvailable()) { + if (this.deps.platform !== "ios" && !this.isAvailable()) { this.fail(s, "unavailable"); return; } - let permission = await this.deps.module.getMicrophonePermissionsAsync(); - if (!this.current(s)) return; - if (!permission.granted && permission.canAskAgain) { - s.prompting = true; - try { - permission = await this.deps.module.requestMicrophonePermissionsAsync(); - } finally { - s.prompting = false; - } - if (!(await this.resumed(s))) { - // The user left the app while (or right after) the prompt was open. - if (this.session === s && !s.cancelled) this.abortQuietly(s); + let lang: string | undefined; + if (this.deps.platform === "ios") { + const primary = locale.split("-")[0].toLowerCase(); + lang = [locale, "de-DE", "en-US", "en-GB"] + .filter((candidate) => candidate.split("-")[0].toLowerCase() === primary) + .find((candidate) => this.deps.module.supportsOnDeviceRecognition(candidate)); + if (!lang) { + this.fail(s, "language-not-supported"); return; } } - if (!this.current(s)) return; - if (!permission.granted) { - this.fail(s, "not-allowed"); - return; + const permissions = [ + [ + this.deps.module.getMicrophonePermissionsAsync.bind(this.deps.module), + this.deps.module.requestMicrophonePermissionsAsync.bind(this.deps.module), + ], + ...(this.deps.platform === "ios" + ? [ + [ + this.deps.module.getSpeechRecognizerPermissionsAsync.bind(this.deps.module), + this.deps.module.requestSpeechRecognizerPermissionsAsync.bind(this.deps.module), + ], + ] + : []), + ]; + for (const [getPermission, requestPermission] of permissions) { + let permission = await getPermission(); + if (!this.current(s)) return; + if (!permission.granted && permission.canAskAgain) { + s.prompting = true; + try { + permission = await requestPermission(); + } finally { + s.prompting = false; + } + if (!(await this.resumed(s))) { + // The user left the app while (or right after) the prompt was open. + if (this.session === s && !s.cancelled) this.abortQuietly(s); + return; + } + } + if (!this.current(s)) return; + if (!permission.granted) { + this.fail(s, "not-allowed"); + return; + } } - const { installedLocales } = await this.deps.module.getSupportedLocales({}); - if (!this.current(s)) return; - const lang = selectInstalledLocale(locale, installedLocales); - if (!lang) { - const attempted = this.deps.downloads ?? downloads; - const key = locale.toLowerCase(); - if (!attempted.has(key)) { - attempted.add(key); - void this.deps.module - .androidTriggerOfflineModelDownload({ locale }) - .catch(() => undefined); + if (this.deps.platform !== "ios") { + const { installedLocales } = await this.deps.module.getSupportedLocales({}); + if (!this.current(s)) return; + lang = selectInstalledLocale(locale, installedLocales); + if (!lang) { + const attempted = this.deps.downloads ?? downloads; + const key = locale.toLowerCase(); + if (!attempted.has(key)) { + attempted.add(key); + void this.deps.module + .androidTriggerOfflineModelDownload({ locale }) + .catch(() => undefined); + } + this.fail(s, "language-not-supported"); + return; } - this.fail(s, "language-not-supported"); - return; } this.lease = s; await this.deps.audio.acquire(() => this.current(s)); @@ -320,6 +369,15 @@ export class VoiceInputController { s.started = true; this.deps.module.start({ lang, + ...(this.deps.platform === "ios" + ? { + iosCategory: { + category: "playAndRecord" as const, + mode: "measurement" as const, + categoryOptions: ["defaultToSpeaker" as const, "allowBluetooth" as const], + }, + } + : {}), requiresOnDeviceRecognition: true, interimResults: true, continuous: false, diff --git a/apps/mobile/src/voice/audioArbiter.test.ts b/apps/mobile/src/voice/audioArbiter.test.ts index ce912ab43..44554b96d 100644 --- a/apps/mobile/src/voice/audioArbiter.test.ts +++ b/apps/mobile/src/voice/audioArbiter.test.ts @@ -34,7 +34,7 @@ it("holds only the latest cue and releases it", async () => { await f.arbiter.speak(cue("a")); await f.arbiter.speak(cue("b")); expect(f.audio.speak).not.toHaveBeenCalled(); - f.arbiter.release(); + await f.arbiter.release(); expect(f.audio.speak).toHaveBeenCalledWith(cue("b")); }); it("drops stale guidance with stable status", async () => { @@ -42,7 +42,7 @@ it("drops stale guidance with stable status", async () => { await f.arbiter.acquire(() => true); await f.arbiter.speak(cue("a")); f.age(10001); - f.arbiter.release(); + await f.arbiter.release(); expect(f.audio.speak).not.toHaveBeenCalled(); expect((await f.arbiter.getStatus()).lastResultCode).toBe("voice-guidance-stale"); }); diff --git a/apps/mobile/src/voice/audioArbiter.ts b/apps/mobile/src/voice/audioArbiter.ts index 44e748464..bd455fa2a 100644 --- a/apps/mobile/src/voice/audioArbiter.ts +++ b/apps/mobile/src/voice/audioArbiter.ts @@ -6,7 +6,7 @@ import { export interface VoiceAudioLease { acquire(isCurrent: () => boolean): Promise; - release(): void; + release(beforeRelease?: () => void): void | Promise; } /** One runtime-wide gate, including callers outside the foreground shell. */ @@ -59,17 +59,32 @@ export function createAudioArbiter( throw new Error("guidance-still-speaking"); } }, - release() { - held = false; - const cue = latest; - latest = null; - if (!cue) return; - if (now() - cue.at > 10000) { - outcome = "voice-guidance-stale"; - return; + release(beforeRelease) { + if (beforeRelease) { + return (async () => { + try { + if (!(await audio.getStatus()).speaking && pendingSpeech === 0) beforeRelease(); + } catch { + // Native session may already have been deactivated by an interruption. + } finally { + releaseHeldCue(); + } + })(); } - outcome = "voice-guidance-released"; - void speak(cue.request); + releaseHeldCue(); + return undefined; }, }; + function releaseHeldCue() { + held = false; + const cue = latest; + latest = null; + if (!cue) return; + if (now() - cue.at > 10000) { + outcome = "voice-guidance-stale"; + return; + } + outcome = "voice-guidance-released"; + void speak(cue.request); + } } diff --git a/apps/web/src/app/(legal)/privacy/content.de.tsx b/apps/web/src/app/(legal)/privacy/content.de.tsx index 94d75b39d..c23fc57f1 100644 --- a/apps/web/src/app/(legal)/privacy/content.de.tsx +++ b/apps/web/src/app/(legal)/privacy/content.de.tsx @@ -884,16 +884,17 @@ export default function PrivacyContentDe({ - In der installierten App nutzt die optionale Sprachsuche ab Android 13 die lokale - Spracherkennung des Betriebssystems, sofern ein lokaler Dienst und das Sprachmodell - verfügbar sind. Sie startet erst, wenn Sie die Mikrofontaste drücken und Android den - Mikrofonzugriff erlauben. Die Audiodaten werden auf Ihrem Gerät verarbeitet und von - OpenMapX weder übertragen noch gespeichert. Das Herunterladen eines fehlenden - Sprachmodells benötigt eine Netzwerkverbindung; die Erkennung selbst nicht. Unter iOS und - älteren Android-Versionen ist die Sprachsuche deaktiviert. Das Transkript wird als - gewöhnlicher Suchtext verwendet. Dafür gelten dieselben Regeln für Suchanfragen und den - Suchverlauf wie bei getipptem Text. Die Spracheingabefunktion protokolliert keine - Transkripte. + In der installierten App nutzt die optionale Sprachsuche unter iOS und ab Android 13 die + lokale Spracherkennung des Betriebssystems, sofern ein lokaler Dienst und das Sprachmodell + verfügbar sind. Sie startet erst, wenn Sie die Mikrofontaste drücken und den + Mikrofonzugriff und unter iOS die Spracherkennung erlauben. Die Audiodaten werden auf + Ihrem Gerät verarbeitet und von OpenMapX weder übertragen noch gespeichert. Das + Herunterladen eines fehlenden Sprachmodells benötigt eine Netzwerkverbindung; die + Erkennung selbst nicht. Unter älteren Android-Versionen ist die Sprachsuche deaktiviert. + Unter iOS muss die angeforderte Sprache offline unterstützt werden; es gibt keinen + Server-Fallback. Das Transkript wird als gewöhnlicher Suchtext verwendet. Dafür gelten + dieselben Regeln für Suchanfragen und den Suchverlauf wie bei getipptem Text. Die + Spracheingabefunktion protokolliert keine Transkripte. diff --git a/apps/web/src/app/(legal)/privacy/content.en.tsx b/apps/web/src/app/(legal)/privacy/content.en.tsx index d89559248..e4fedc737 100644 --- a/apps/web/src/app/(legal)/privacy/content.en.tsx +++ b/apps/web/src/app/(legal)/privacy/content.en.tsx @@ -817,13 +817,15 @@ export default function PrivacyContent({ In the installed app, optional voice search uses the operating system's on-device - recogniser on Android 13 or later when an on-device service and the language model are - available. It starts only when you press the microphone button and grant Android - microphone permission. The audio is processed on your device and is not transmitted or - stored by OpenMapX. Downloading a missing language model requires a network connection; - recognition itself does not. Voice search is disabled on iOS and older Android versions. - The transcript is used as ordinary search text and follows the same search-request and - search-history rules as typed text. Transcripts are not logged by the voice-input feature. + recogniser on iOS and Android 13 or later when an on-device service and the language model + are available. It starts only when you press the microphone button and grant microphone + permission (plus speech-recognition authorization on iOS). The audio is processed on your + device and is not transmitted or stored by OpenMapX. Downloading a missing language model + requires a network connection; recognition itself does not. Voice search is disabled on + older Android versions. On iOS, each requested language must support offline recognition; + there is no server fallback. The transcript is used as ordinary search text and follows + the same search-request and search-history rules as typed text. Transcripts are not logged + by the voice-input feature. diff --git a/apps/web/src/components/search/VoiceSearchButton.test.tsx b/apps/web/src/components/search/VoiceSearchButton.test.tsx index 43a38138f..86c629c77 100644 --- a/apps/web/src/components/search/VoiceSearchButton.test.tsx +++ b/apps/web/src/components/search/VoiceSearchButton.test.tsx @@ -333,17 +333,24 @@ describe("VoiceSearchButton inside the installed shell", () => { } }); - it.each([ - ["not-allowed", "voiceErrorNotAllowedApp"], - ["language-not-supported", "voiceErrorLanguageApp"], - ["audio-capture", "voiceErrorNoMicrophone"], - ["no-speech", "voiceErrorNoSpeech"], - ["busy", "voiceErrorGeneric"], - ["unavailable", "voiceErrorGeneric"], - ["aborted", "voiceErrorGeneric"], - ["generic", "voiceErrorGeneric"], - ])("maps native %s to %s", async (error, key) => { + it.each( + [ + ["not-allowed", "voiceErrorNotAllowedApp"], + ["language-not-supported", "voiceErrorLanguageApp"], + ["audio-capture", "voiceErrorNoMicrophone"], + ["no-speech", "voiceErrorNoSpeech"], + ["busy", "voiceErrorGeneric"], + ["unavailable", "voiceErrorGeneric"], + ["aborted", "voiceErrorGeneric"], + ["generic", "voiceErrorGeneric"], + ].flatMap(([error, key]) => [ + [error, key, "android"], + [error, key.endsWith("App") ? `${key}Ios` : key, "ios"], + ]), + )("maps native %s to %s on %s", async (error, key, platform) => { const h = nativeRuntime(); + if (runtime.current?.handshake) + runtime.current.handshake.platform = platform as "ios" | "android"; try { render(); fireEvent.click(await screen.findByLabelText("search.voiceSearchAriaLabel")); diff --git a/apps/web/src/components/search/VoiceSearchButton.tsx b/apps/web/src/components/search/VoiceSearchButton.tsx index 0528d7e9a..efba369dd 100644 --- a/apps/web/src/components/search/VoiceSearchButton.tsx +++ b/apps/web/src/components/search/VoiceSearchButton.tsx @@ -6,6 +6,7 @@ import IconButton from "@mui/material/IconButton"; import Snackbar from "@mui/material/Snackbar"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useMobileRuntimeContext } from "@/lib/mobile/MobileRuntimeProvider"; import { isInstalledShell, shellFeatureBoundary } from "@/lib/mobile/mobileShellEnvironment"; import { useNativeVoiceInput } from "@/lib/mobile/useNativeVoiceInput"; import { useHydrated } from "@/lib/useHydrated"; @@ -123,6 +124,7 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { const voiceSessionRef = useRef(0); // Surfaced when dictation fails (permission blocked, no network, etc.) so the // mic button reports the reason instead of silently doing nothing. + const platform = useMobileRuntimeContext().handshake?.platform; const [voiceError, setVoiceError] = useState(null); // Keep the latest onResult without re-creating startVoiceSearch each render. @@ -144,9 +146,13 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { setListening(false); const key = error === "not-allowed" - ? "voiceErrorNotAllowedApp" + ? platform === "ios" + ? "voiceErrorNotAllowedAppIos" + : "voiceErrorNotAllowedApp" : error === "language-not-supported" - ? "voiceErrorLanguageApp" + ? platform === "ios" + ? "voiceErrorLanguageAppIos" + : "voiceErrorLanguageApp" : voiceErrorKey(error); setVoiceError(t(key)); }, @@ -232,7 +238,7 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { if (voiceSessionRef.current !== session) return; // cancelled meanwhile beginRecognition(); }); - }, [speechCtor, locale, t, inShell, nativeInput]); + }, [speechCtor, locale, t, inShell, nativeInput, platform]); const toggleVoiceSearch = useCallback(() => { if (listening) { diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index f833653d4..2257a7a68 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -37,17 +37,17 @@ records. ## The authority matrix -| Concern | Browser or PWA | Installed shell | -| --------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Rendering, search and route planning | Web UI | Web UI | -| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | -| Ground and transit navigation | Browser engine | Native coordinator, reported available | -| Background location, local alerts and speech | Subject to browser limits | Native; alerts need the notification grant | -| Durable native session | Not used | SQLite session, resumed after a process restart | -| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | -| Deep-link delivery | Browser flows | OS URLs validated, queued through document loading, delivered after bridge negotiation | -| Community frontend bundles | Disabled | Disabled | -| Microphone input | Subject to browser support and permission | Native on-device search on Android 13+ when the voice probe answers available; WebView capture disabled | +| Concern | Browser or PWA | Installed shell | +| --------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Rendering, search and route planning | Web UI | Web UI | +| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | +| Ground and transit navigation | Browser engine | Native coordinator, reported available | +| Background location, local alerts and speech | Subject to browser limits | Native; alerts need the notification grant | +| Durable native session | Not used | SQLite session, resumed after a process restart | +| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | +| Deep-link delivery | Browser flows | OS URLs validated, queued through document loading, delivered after bridge negotiation | +| Community frontend bundles | Disabled | Disabled | +| Microphone input | Subject to browser support and permission | Native on-device search on iOS and Android 13+ when the voice probe answers available; WebView capture disabled | The immutable descriptor is injected before page scripts run. Shell restrictions use that descriptor, so they apply during negotiation, incompatibility and errors. diff --git a/docs/docs/developer/mobile-feasibility.md b/docs/docs/developer/mobile-feasibility.md index d053c6629..a3d6f799c 100644 --- a/docs/docs/developer/mobile-feasibility.md +++ b/docs/docs/developer/mobile-feasibility.md @@ -57,9 +57,9 @@ fixtures. ## Voice input qualification -The [native voice-input design](./voice-input.md) uses Android 13+ on-device +The [native voice-input design](./voice-input.md) uses iOS and Android 13+ on-device recognition behind the post-handshake `voice.probe` availability check. WebView microphone -capture remains denied. iOS and older Android versions keep voice input off. +capture remains denied. Older Android versions keep voice input off. iOS requires locale-specific on-device support and our fail-closed package patch. Historical launch reports do not qualify this feature. On the candidate binary, test airplane-mode recognition with installed German diff --git a/docs/docs/developer/mobile-release.md b/docs/docs/developer/mobile-release.md index d31f7af06..c43b70048 100644 --- a/docs/docs/developer/mobile-release.md +++ b/docs/docs/developer/mobile-release.md @@ -36,8 +36,8 @@ there is no runtime server selector. The installed WebView is restricted to the compiled origin. WebView camera, microphone and geolocation access are disabled. Native on-device voice search -is designed for Android 13+ with an available on-device service; it does not -grant microphone access to the page. iOS voice input stays disabled. +is designed for iOS and Android 13+ with available on-device language support; it does not +grant microphone access to the page. iOS uses our fail-closed recognizer patch; candidate device qualification is still required. See [On-device voice input](./voice-input.md) for the recognition boundary and open device checks. Community frontend runtime code is disabled in the app, ordinary web app and PWA. Declarative metadata and permitted static diff --git a/docs/docs/developer/mobile-store-operations.md b/docs/docs/developer/mobile-store-operations.md index 4601d9ae3..6cb744910 100644 --- a/docs/docs/developer/mobile-store-operations.md +++ b/docs/docs/developer/mobile-store-operations.md @@ -118,10 +118,11 @@ and CarPlay, disabled. Compare generated entitlements and the final archive with ## Voice-search disclosure and qualification -Native voice search is designed for Android 13+ on-device recognition only. -It requests `RECORD_AUDIO` on a microphone tap; the WebView still cannot capture -audio or video. iOS voice input remains disabled despite the linked package -requiring microphone and speech-recognition purpose strings. +Native voice search uses on-device recognition on iOS and Android 13+. +It requests microphone permission on a microphone tap and speech-recognition +authorization on iOS; the WebView still cannot capture audio or video. iOS requires +the fail-closed package patch and locale-specific on-device support. Both iOS +purpose strings describe the enabled local recognition path. Review the `voice-search-audio` practice and regenerated store worksheets with the candidate. Audio is processed locally without OpenMapX transmission or diff --git a/docs/docs/developer/voice-input.md b/docs/docs/developer/voice-input.md index c29e52af1..6f16bf442 100644 --- a/docs/docs/developer/voice-input.md +++ b/docs/docs/developer/voice-input.md @@ -15,7 +15,7 @@ The website continues to use browser speech recognition, whose processing may us The native dependency is `expo-speech-recognition` **57.1.1**, licensed under MIT and maintained by jamsch. It is a community package, not an official Expo package. -It wraps Android's `SpeechRecognizer` rather than bundling a separate recognition engine. +It wraps Android's `SpeechRecognizer` and Apple's `SFSpeechRecognizer` rather than bundling a separate engine. | Alternative | Reason for rejection | | -------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -29,7 +29,7 @@ A mobile distribution notice for this MIT dependency remains a release gap; no n ## Android guarantee and language readiness -Voice input is enabled only on Android 13 or later (API 33+) with an available on-device service. +On Android, voice input is enabled only on Android 13 or later (API 33+) with an available on-device service. Every recognition request sets `requiresOnDeviceRecognition: true`. On this path the pinned package calls `SpeechRecognizer.createOnDeviceSpeechRecognizer`, with no network recogniser fallback. OpenMapX never sets `androidRecognitionServicePackage`. @@ -45,12 +45,73 @@ It triggers Android's model download once per requested locale per app run. Android owns the download dialog on API 33 and the background download on API 34+. A download request is not proof that installation succeeded; the next start checks again. -## iOS remains disabled - -The package's iOS path sets `requiresOnDeviceRecognition` only when supported and otherwise proceeds with server recognition. -The gap is tracked in upstream issue `jamsch/expo-speech-recognition#169`. -The app therefore reports `voice.availability` with `available: false` on iOS until upstream fails closed. -The linked package requires microphone and speech-recognition purpose strings in the iOS binary even while the feature is disabled. +## iOS on-device guarantee + +Our committed pnpm patch for `expo-speech-recognition@57.1.1` closes upstream +[jamsch/expo-speech-recognition#169](https://github.com/jamsch/expo-speech-recognition/issues/169). +Before constructing the native recognizer, start checks the requested locale's +recognizer for existence, availability and on-device support. The recognizer +rechecks these conditions before preparing a request or activating the audio session. +Failure emits `language-not-supported` and `end`, without capture. Every request +unconditionally sets `requiresOnDeviceRecognition` before `recognitionTask`; +contextual strings, task hints and punctuation never change that flag. +Apple warns that the flag is honored only when +[`supportsOnDeviceRecognition` is true](https://developer.apple.com/documentation/speech/sfspeechrecognitionrequest/requiresondevicerecognition). +There is no server fallback or iOS model-download trigger. + +The patched synchronous availability API accepts an optional locale. Its JS wrapper +passes this argument only on iOS, preserving Android's zero-argument API. +The voice probe checks `de-DE`, `en-US` and `en-GB`; one supported locale enables it. +Start checks the requested locale first, then candidates with the same primary language. +iOS `getSupportedLocales` is not an installed-model inventory and is not used. +Readiness can still change after probing; the native guards enforce the boundary again. + +OpenMapX conservatively checks and, when askable, requests both microphone and +speech-recognition authorization, mapping either denial to `not-allowed`. +The package skips its speech-authorization guard for on-device requests, while +[Apple's authorization guidance](https://developer.apple.com/documentation/speech/asking-permission-to-use-speech-recognition) +does not explicitly exempt this path. We therefore do not rely on that omission. +System permission alerts can make the app inactive; `suspend()` exempts prompts +and start waits for active state before capture. Both usage strings are localized. + +iOS words the Speech Recognition alert itself: "Speech data from this app will be +sent to Apple to process your requests. This will also help Apple improve its speech +recognition technology." Our usage string follows it. That system text is the same +for every app and does not reflect `requiresOnDeviceRecognition`, so it contradicts +the on-device guarantee. If a real iPhone shows that on-device recognition works +without speech-recognition authorization, the controller should stop requesting it +so this alert never appears; until then it is requested. + +The existing warning points to iPhone Privacy & Security settings for permissions. +For missing offline language support it suggests Settings > General > Keyboard, +adding the language's keyboard and enabling Dictation, following Apple's +[Dictation](https://support.apple.com/guide/iphone/iph2c0651d2/ios) and +[keyboard](https://support.apple.com/guide/iphone/iph73b71eb/ios) guides. +This is setup guidance, not a promise that a model becomes available on every iPhone. + +Recognition explicitly uses `playAndRecord` / `measurement` with `defaultToSpeaker` +and `allowBluetooth`, matching package defaults without ducking options. +After native end (or bounded abort settlement), the arbiter checks guidance status +and deactivates with `notifyOthersOnDeactivation` unless guidance is speaking. +Only then does it release a held cue, whose native module configures `playback` / +`voicePrompt` itself. The lease stays owned until hand-back completes. + +Installed-source mutation tests and the release gate verify the two guards and +unconditional request flag. Controller/arbiter tests cover locale selection, +permissions, prompt inactivity and hand-back ordering. The existing SwiftPM runner +compiles navigation audio policy only; duplicating recognizer policy in that module +would not exercise the patched dependency. No native recognizer execution or iPhone +privacy/device qualification is claimed by these structural and JavaScript checks. + +On an iOS 26.4 simulator, a debug build of this design showed the voice button +(the simulator reports on-device support for `en-US` only), the microphone and +Speech Recognition alerts with our usage strings, the iPhone wording for a refused +permission, and the iPhone wording for German without recording or a prompt. +Recording itself was not exercised: without host microphone access the simulator's +audio input times out and the package aborts the app while opening the input node, +which does not occur on a device with a microphone. The app does not start on the +iOS 27 simulator at all, independent of voice input: apps built with the iOS 27 SDK +must adopt the scene-based life cycle, which the generated `AppDelegate` does not yet. ## Permission and lifecycle diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index 79b92f090..2a72b1469 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -68,12 +68,14 @@ is hidden on unsupported browsers. Browser recognition may send audio to the browser vendor's speech service; check its privacy settings before speaking. The installed app uses the same button and warning snackbar with native -on-device recognition on Android 13+ when the service is available. The first -tap requests Android microphone permission. If denied, the warning points to -Android settings. There is no additional settings button or custom dialog. +on-device recognition on iOS and Android 13+ when offline support is available. The first +tap requests microphone permission and, on iOS, speech-recognition permission. If denied, the warning points to +platform settings. There is no additional settings button or custom dialog. If the language model is missing, the warning explains this and Android's own model download is requested. Downloads need a network connection; recognition -does not. iOS and older Android versions do not offer app voice search. +does not. iOS checks each requested language without an app-triggered model download; +its warning suggests adding a keyboard and enabling Dictation in iPhone settings. +Older Android versions do not offer app voice search. Tap again to finish an utterance. Leaving the page or backgrounding the app cancels listening. OpenMapX does not transmit or store app microphone audio. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index efd7dead2..06cf8c3cc 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -364,6 +364,8 @@ "voiceErrorNotAllowed": "Mikrofonzugriff ist blockiert. Erlaube ihn in den Website-Einstellungen deines Browsers und versuche es erneut.", "voiceErrorNotAllowedApp": "Erlaube den Mikrofonzugriff für OpenMapX in den Android-Einstellungen und versuche es erneut.", "voiceErrorLanguageApp": "Die Offline-Spracherkennung für diese Sprache ist auf diesem Gerät nicht installiert. Der Download durch das System wurde gestartet.", + "voiceErrorNotAllowedAppIos": "Erlaube Mikrofon und Spracherkennung für OpenMapX unter iPhone-Einstellungen > Datenschutz & Sicherheit.", + "voiceErrorLanguageAppIos": "Offline-Spracherkennung für diese Sprache ist auf diesem iPhone nicht verfügbar. Füge unter Einstellungen > Allgemein > Tastatur ihre Tastatur hinzu und aktiviere die Diktierfunktion; die Verfügbarkeit hängt von Gerät und Sprache ab.", "voiceErrorNoMicrophone": "Es wurde kein Mikrofon gefunden.", "voiceErrorNetwork": "Die Sprachsuche benötigt eine Internetverbindung.", "voiceErrorLanguage": "Die Sprachsuche ist für diese Sprache nicht verfügbar.", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 1673999c4..d00429509 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -364,6 +364,8 @@ "voiceErrorNotAllowed": "Microphone access is blocked. Allow it in your browser's site settings, then try again.", "voiceErrorNotAllowedApp": "Allow microphone access for OpenMapX in Android settings, then try again.", "voiceErrorLanguageApp": "Offline speech for this language isn't installed on this device. The system download was started.", + "voiceErrorNotAllowedAppIos": "Allow microphone and speech recognition for OpenMapX in iPhone Settings > Privacy & Security.", + "voiceErrorLanguageAppIos": "Offline speech recognition for this language is unavailable on this iPhone. In Settings > General > Keyboard, add its keyboard and enable Dictation; availability varies by device and language.", "voiceErrorNoMicrophone": "No microphone was found.", "voiceErrorNetwork": "Voice search needs an internet connection.", "voiceErrorLanguage": "Voice search isn't available for this language.", diff --git a/patches/expo-speech-recognition@57.1.1.patch b/patches/expo-speech-recognition@57.1.1.patch index ee5937c8f..c46db1052 100644 --- a/patches/expo-speech-recognition@57.1.1.patch +++ b/patches/expo-speech-recognition@57.1.1.patch @@ -29,3 +29,150 @@ index 4a0604ab8ffd5112ec0bb770be56b3bc62dfcc80..ab2864a256c0d1b740e1f51c9ab022e5 // If the user opted to stop if (recognitionState == RecognitionState.STOPPING) { +diff --git a/build/ExpoSpeechRecognitionModule.js b/build/ExpoSpeechRecognitionModule.js +index f3ea8674b37c378266994fcc84f8fc5cc54cd826..9ef922d8f04173a845e2e74910f0f6f14eeee612 100644 +--- a/build/ExpoSpeechRecognitionModule.js ++++ b/build/ExpoSpeechRecognitionModule.js +@@ -1,3 +1,4 @@ ++import { Platform } from "react-native"; + import { requireNativeModule } from "expo"; + // It loads the native module object from the JSI or falls back to + // the bridge module (from NativeModulesProxy) if the remote debugger is on. +@@ -7,3 +8,6 @@ const abort = ExpoSpeechRecognitionModule.abort; + ExpoSpeechRecognitionModule.abort = () => abort(); + ExpoSpeechRecognitionModule.stop = () => stop(); + //# sourceMappingURL=ExpoSpeechRecognitionModule.js.map ++const supportsOnDeviceRecognition = ExpoSpeechRecognitionModule.supportsOnDeviceRecognition; ++ExpoSpeechRecognitionModule.supportsOnDeviceRecognition = (locale) => ++ Platform.OS === "ios" ? supportsOnDeviceRecognition(locale) : supportsOnDeviceRecognition(); +diff --git a/build/ExpoSpeechRecognitionModule.types.d.ts b/build/ExpoSpeechRecognitionModule.types.d.ts +index 5dad166c1368c1b101e4361bf3f7b13c0437cd46..3ba5e4f5e5503ea8fa45a9e440fc2818256024d1 100644 +--- a/build/ExpoSpeechRecognitionModule.types.d.ts ++++ b/build/ExpoSpeechRecognitionModule.types.d.ts +@@ -662,7 +662,7 @@ export declare class ExpoSpeechRecognitionModuleType extends NativeModule Bool in +- let recognizer = SFSpeechRecognizer() +- return recognizer?.supportsOnDeviceRecognition ?? false ++ Function("supportsOnDeviceRecognition") { (locale: String?) -> Bool in ++ let recognizer: SFSpeechRecognizer? ++ if let locale { ++ recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) ++ } else { ++ recognizer = SFSpeechRecognizer() ++ } ++ guard let recognizer else { return false } ++ return recognizer.isAvailable && recognizer.supportsOnDeviceRecognition + } + + Function("supportsRecording") { () -> Bool in +diff --git a/ios/ExpoSpeechRecognizer.swift b/ios/ExpoSpeechRecognizer.swift +index b27842501f72e1a5a9382636397cbef30d5a2b30..1dfca5b7087703481c5703f6b8fa7113b77df81b 100644 +--- a/ios/ExpoSpeechRecognizer.swift ++++ b/ios/ExpoSpeechRecognizer.swift +@@ -215,6 +215,15 @@ actor ExpoSpeechRecognizer: ObservableObject { + self.outputFileUrl = nil + self.speechStartHandler = speechStartHandler + ++ // Recheck the actual recognizer immediately before request/capture setup. ++ if options.requiresOnDeviceRecognition { ++ guard let recognizer, recognizer.isAvailable, recognizer.supportsOnDeviceRecognition else { ++ errorHandler(RecognizerError.nilRecognizer) ++ reset(andEmitEnd: true) ++ return ++ } ++ } ++ + guard let recognizer, recognizer.isAvailable else { + errorHandler(RecognizerError.recognizerIsUnavailable) + reset(andEmitEnd: true) +@@ -585,9 +594,7 @@ actor ExpoSpeechRecognizer: ObservableObject { + // These won't get emitted to the user, however + request.shouldReportPartialResults = options.interimResults || !options.continuous + +- if recognizer.supportsOnDeviceRecognition { +- request.requiresOnDeviceRecognition = options.requiresOnDeviceRecognition +- } ++ request.requiresOnDeviceRecognition = options.requiresOnDeviceRecognition + + if let taskHint = options.iosTaskHint { + request.taskHint = taskHint.sfSpeechRecognitionTaskHint +diff --git a/src/ExpoSpeechRecognitionModule.ts b/src/ExpoSpeechRecognitionModule.ts +index bf13f2a8b2dbe42e943b631ef5d919e7bd1614db..566eda262a1cdbf35693126853057325321f325c 100644 +--- a/src/ExpoSpeechRecognitionModule.ts ++++ b/src/ExpoSpeechRecognitionModule.ts +@@ -1,3 +1,4 @@ ++import { Platform } from "react-native"; + import { requireNativeModule } from "expo"; + + import type { ExpoSpeechRecognitionModuleType } from "./ExpoSpeechRecognitionModule.types"; +@@ -12,3 +13,7 @@ const abort = ExpoSpeechRecognitionModule.abort; + + ExpoSpeechRecognitionModule.abort = () => abort(); + ExpoSpeechRecognitionModule.stop = () => stop(); ++ ++const supportsOnDeviceRecognition = ExpoSpeechRecognitionModule.supportsOnDeviceRecognition; ++ExpoSpeechRecognitionModule.supportsOnDeviceRecognition = (locale?: string) => ++ Platform.OS === "ios" ? supportsOnDeviceRecognition(locale) : supportsOnDeviceRecognition(); +diff --git a/src/ExpoSpeechRecognitionModule.types.ts b/src/ExpoSpeechRecognitionModule.types.ts +index acb7061c6f259ac0f6266b9ddedc62f54aa6ffef..85fef9a223aa4d855eecb7bed48384ad6d2f5414 100644 +--- a/src/ExpoSpeechRecognitionModule.types.ts ++++ b/src/ExpoSpeechRecognitionModule.types.ts +@@ -708,7 +708,7 @@ export declare class ExpoSpeechRecognitionModuleType extends NativeModuleexpo-sqlite: '-' patchedDependencies: - expo-speech-recognition@57.1.1: 3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264 + expo-speech-recognition@57.1.1: b5f9141186cf4462d4af1512b3de5b955ab11bf3296b1ba07aa3979708e623d8 react-native-webview@13.16.1: c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5 importers: @@ -458,7 +458,7 @@ importers: version: 57.0.21(258a15991fb2f6b5cd5b08f93c378644) expo-speech-recognition: specifier: 57.1.1 - version: 57.1.1(patch_hash=3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + version: 57.1.1(patch_hash=b5f9141186cf4462d4af1512b3de5b955ab11bf3296b1ba07aa3979708e623d8)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) expo-splash-screen: specifier: ~57.0.9 version: 57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) @@ -18424,7 +18424,7 @@ snapshots: expo-server@57.0.3: {} - ? expo-speech-recognition@57.1.1(patch_hash=3cd406145f9e0c15219f6e1a211c3f2229cc69bd07cee035ec98cf5a12bed264)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + ? expo-speech-recognition@57.1.1(patch_hash=b5f9141186cf4462d4af1512b3de5b955ab11bf3296b1ba07aa3979708e623d8)(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) : dependencies: expo: 57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3) react: 19.3.1 From 0acc380f91778505713b16ab1bb4c89e80edd645 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:59:20 +0100 Subject: [PATCH 5/5] test(mobile): allow Android Auto's car-destinations capability in the hello fixture --- apps/mobile/src/App.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 56f70c1a9..97e7d1542 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -437,9 +437,12 @@ describe("App bridge channel", () => { backgroundLocation: z.boolean(), localNotifications: z.boolean(), speech: z.boolean(), + carDestinations: z.boolean().optional(), }) .strict(); - expect(oldCapabilities.parse(reply?.payload?.capabilities)).toEqual(NAVIGATION_CAPABILITIES); + expect(oldCapabilities.parse(reply?.payload?.capabilities)).toMatchObject( + NAVIGATION_CAPABILITIES, + ); expect(mockCreateForegroundNavigation).toHaveBeenCalledTimes(1); expect(mockCreateForegroundNavigation.mock.calls[0]?.[0]).toMatchObject({ apiOrigin: "https://openmapx.com",