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..335bbdfc2 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 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 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. 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. + ## 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..828354748 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 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 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", + "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. 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/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..aa3612056 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 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 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. 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. + ## 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..975f1b462 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 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 8040169f1..bbf8f66ba 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 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 07f69564f..925eda1ec 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 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.", 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 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.", 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..d8c71f9ba 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,10 @@ */ import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +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, "../../.."); @@ -144,6 +148,39 @@ 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" }); +} + +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/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/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/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..97e7d1542 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 = {}; @@ -401,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, { @@ -421,13 +428,99 @@ 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(), + carDestinations: z.boolean().optional(), + }) + .strict(); + expect(oldCapabilities.parse(reply?.payload?.capabilities)).toMatchObject( + 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 fireEvent(shellView.getByTestId("product-shell"), "touchStart"); + 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("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(); @@ -732,3 +825,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..0131046ec 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,17 @@ import { useRef, useState, } from "react"; -import { Alert, Linking, 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"; import { buildChannelBootstrapScript } from "./bridge/outboundScript"; import { getRuntimeConfig, type MobileRuntimeConfig } from "./config/runtimeConfig"; @@ -36,6 +46,8 @@ 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"; /** * The entire visible product is the web UI inside one WebView pointed at the @@ -86,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)); @@ -114,15 +142,55 @@ 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") { + // 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; + } + 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 +215,11 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement onNavigatingChanged: setNavigating, }).catch(() => null); } - return { shell: composed, navigation }; - }, [config, locale, prompter, services, setNavigating]); + return { shell: composed, navigation, voice }; + }, [config, locale, prompter, services, setNavigating, voiceIntent]); + useEffect(() => { + if (visibility !== "active") voice.suspend(); + }, [visibility, voice]); const deliverLink = useCallback( (intent: DeepLinkIntent) => { shell.bridge.send("deep-link.open", intent); @@ -191,9 +262,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 +296,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 +317,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 } }) => { @@ -362,7 +436,12 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement ); return ( - + ("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..e8c7dc78a --- /dev/null +++ b/apps/mobile/src/voice/VoiceInputController.test.ts @@ -0,0 +1,484 @@ +import { createAudioArbiter } from "./audioArbiter"; +import { mapVoiceError, selectInstalledLocale, VoiceInputController } from "./VoiceInputController"; + +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((_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"], + })), + 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: audioOverride ?? 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, false], + ["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, "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, + 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); + }); +}); + +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 new file mode 100644 index 000000000..754ec68a3 --- /dev/null +++ b/apps/mobile/src/voice/VoiceInputController.ts @@ -0,0 +1,424 @@ +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" + | "getSpeechRecognizerPermissionsAsync" + | "requestSpeechRecognizerPermissionsAsync" + | "setAudioSessionActiveIOS" + | "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 { + 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 && + 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 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) { + 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; + let released: Promise | undefined; + if (this.lease === s) { + 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) { + if (released) void released.then(s.drain); + else 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; + if (released) void released.then(s.drain); + else 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.deps.platform !== "ios" && !this.isAvailable()) { + this.fail(s, "unavailable"); + return; + } + 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; + } + } + 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; + } + } + 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.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, + ...(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, + 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..44554b96d --- /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(); + await 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); + await 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..bd455fa2a --- /dev/null +++ b/apps/mobile/src/voice/audioArbiter.ts @@ -0,0 +1,90 @@ +import { + type NavigationAudio, + type SpeakRequest, + validateSpeakRequest, +} from "../audio/navigationAudio"; + +export interface VoiceAudioLease { + acquire(isCurrent: () => boolean): Promise; + release(beforeRelease?: () => void): void | Promise; +} + +/** 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(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(); + } + })(); + } + 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/mobile/src/voice/userIntentGate.test.ts b/apps/mobile/src/voice/userIntentGate.test.ts new file mode 100644 index 000000000..651082b67 --- /dev/null +++ b/apps/mobile/src/voice/userIntentGate.test.ts @@ -0,0 +1,37 @@ +import { createUserIntentGate, USER_INTENT_WINDOW_MS } from "./userIntentGate"; + +describe("voice user-intent gate", () => { + 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/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..c23fc57f1 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
@@ -866,23 +866,37 @@ 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 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. + + 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..e4fedc737 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
@@ -799,20 +799,33 @@ 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 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 7b50696d1..86c629c77 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,208 @@ 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"], + ].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")); + 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..efba369dd 100644 --- a/apps/web/src/components/search/VoiceSearchButton.tsx +++ b/apps/web/src/components/search/VoiceSearchButton.tsx @@ -6,7 +6,9 @@ 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 { useMobileRuntimeContext } from "@/lib/mobile/MobileRuntimeProvider"; +import { isInstalledShell, shellFeatureBoundary } from "@/lib/mobile/mobileShellEnvironment"; +import { useNativeVoiceInput } from "@/lib/mobile/useNativeVoiceInput"; import { useHydrated } from "@/lib/useHydrated"; interface SpeechRecognitionAlternativeLike { @@ -97,7 +99,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 +110,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; @@ -121,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. @@ -130,6 +134,31 @@ 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" + ? platform === "ios" + ? "voiceErrorNotAllowedAppIos" + : "voiceErrorNotAllowedApp" + : error === "language-not-supported" + ? platform === "ios" + ? "voiceErrorLanguageAppIos" + : "voiceErrorLanguageApp" + : voiceErrorKey(error); + setVoiceError(t(key)); + }, + }); + return; + } if (!speechCtor) return; setVoiceError(null); const session = ++voiceSessionRef.current; @@ -209,17 +238,22 @@ export function VoiceSearchButton({ onResult }: VoiceSearchButtonProps) { if (voiceSessionRef.current !== session) return; // cancelled meanwhile beginRecognition(); }); - }, [speechCtor, locale, t]); + }, [speechCtor, locale, t, inShell, nativeInput, platform]); 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 +265,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..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 | 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. @@ -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..a3d6f799c 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 iOS and Android 13+ on-device +recognition behind the post-handshake `voice.probe` availability check. WebView microphone +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 +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..c43b70048 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 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 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..6cb744910 100644 --- a/docs/docs/developer/mobile-store-operations.md +++ b/docs/docs/developer/mobile-store-operations.md @@ -116,6 +116,24 @@ 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 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 +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..6f16bf442 --- /dev/null +++ b/docs/docs/developer/voice-input.md @@ -0,0 +1,210 @@ +--- +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` and Apple's `SFSpeechRecognizer` rather than bundling a separate 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 + +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`. +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 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 + +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. + +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. +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..2a72b1469 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -61,13 +61,27 @@ 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 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 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. +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..06cf8c3cc 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -362,6 +362,10 @@ "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.", + "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 b435db334..d00429509 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -362,6 +362,10 @@ "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.", + "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 new file mode 100644 index 000000000..c46db1052 --- /dev/null +++ b/patches/expo-speech-recognition@57.1.1.patch @@ -0,0 +1,178 @@ +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/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 NativeModule(); + + ArrayList requestedAndroidPermissions = new ArrayList<>(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f688ed907..db26ff928 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: b5f9141186cf4462d4af1512b3de5b955ab11bf3296b1ba07aa3979708e623d8 + 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=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(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.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=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 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.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/