diff --git a/.changeset/native-intake-protocol.md b/.changeset/native-intake-protocol.md new file mode 100644 index 000000000..7919534be --- /dev/null +++ b/.changeset/native-intake-protocol.md @@ -0,0 +1,6 @@ +--- +"@openmapx/extension-cli": patch +--- + +Rebuild the public CLI bundle after shared core native-intake contracts and bridge +version updates. Extension commands retain their existing behavior. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b27c22f78..9a956beb9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -47,6 +47,7 @@ const config: ExpoConfig = { assetBundlePatterns: ["assets/**"], ios: { bundleIdentifier: mobile.appId, + ...(mobile.appleTeamId && { appleTeamId: mobile.appleTeamId }), // Phone is the supported first-release form factor; no tablet layout is // claimed anywhere in the store metadata. supportsTablet: false, @@ -118,6 +119,7 @@ const config: ExpoConfig = { // entitlement, the bundle-identifier URL scheme). "./plugins/withOpenMapXNativeConfig", "./plugins/withNavigationSurfaces", + "./plugins/withNativeIntake", [ "expo-build-properties", { diff --git a/apps/mobile/config/mapIntents.test.ts b/apps/mobile/config/mapIntents.test.ts index 496f8f975..67c39a367 100644 --- a/apps/mobile/config/mapIntents.test.ts +++ b/apps/mobile/config/mapIntents.test.ts @@ -3,12 +3,14 @@ import config from "../app.config"; describe("Android map chooser registration", () => { it("claims VIEW geo links with default and browsable categories, without website verification", () => { - expect(config.android?.intentFilters).toEqual([ - { - action: "VIEW", - category: ["DEFAULT", "BROWSABLE"], - data: [{ scheme: "geo" }], - }, - ]); + expect(config.android?.intentFilters).toEqual( + expect.arrayContaining([ + { + action: "VIEW", + category: ["DEFAULT", "BROWSABLE"], + data: [{ scheme: "geo" }], + }, + ]), + ); }); }); diff --git a/apps/mobile/config/nativeIntakeConfig.ts b/apps/mobile/config/nativeIntakeConfig.ts new file mode 100644 index 000000000..0b5c989e8 --- /dev/null +++ b/apps/mobile/config/nativeIntakeConfig.ts @@ -0,0 +1,117 @@ +import { THEME_COLORS, THEME_CORNER_RADIUS } from "../../../packages/core/src/theme.ts"; + +export const TARGET = "OpenMapXShareExtension"; +const TYPES = ["com.topografix.gpx", "public.geojson", "com.google.earth.kml"]; +const MIMES = [ + "application/gpx+xml", + "application/geo+json", + "application/vnd.google-earth.kml+xml", +]; +export function intakeAndroidFilters(): Array<{ + action: string; + category: string[]; + data: Array<{ mimeType: string; scheme?: string }>; +}> { + const geo = MIMES.map((mimeType) => ({ mimeType })); + return [ + { + action: "SEND", + category: ["DEFAULT"], + data: [...geo, { mimeType: "text/plain" }, { mimeType: "text/uri-list" }], + }, + { action: "SEND_MULTIPLE", category: ["DEFAULT"], data: geo }, + { action: "VIEW", category: ["DEFAULT"], data: geo.map((d) => ({ ...d, scheme: "content" })) }, + ]; +} +export function intakeImportedTypes() { + return TYPES.map((UTTypeIdentifier, i) => ({ + UTTypeIdentifier, + UTTypeDescription: ["GPX", "GeoJSON", "KML"][i], + UTTypeConformsTo: [i === 1 ? "public.json" : "public.xml"], + UTTypeTagSpecification: { + "public.filename-extension": [["gpx"], ["geojson"], ["kml"]][i], + "public.mime-type": [MIMES[i]], + }, + })); +} +export function intakeDocumentTypes() { + return [ + { + CFBundleTypeName: "Geographic file", + CFBundleTypeRole: "Viewer", + LSHandlerRank: "Alternate", + LSItemContentTypes: TYPES, + }, + ]; +} +export function intakeShareInfo(appId: string) { + const geo = TYPES.map( + (type) => `ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "${type}"`, + ).join(" OR "); + const text = + '((ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text") AND NOT ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url")'; + const rule = `extensionItems.@count == 1 AND SUBQUERY(extensionItems, $item, $item.attachments.@count >= 1 AND $item.attachments.@count <= 5 AND (SUBQUERY($item.attachments, $attachment, (${geo})).@count == $item.attachments.@count OR (SUBQUERY($item.attachments, $attachment, (${geo})).@count == 0 AND SUBQUERY($item.attachments, $attachment, ${text}).@count >= 1))).@count == 1`; + return { + CFBundleDisplayName: "OpenMapX", + CFBundleExecutable: "$(EXECUTABLE_NAME)", + CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)", + CFBundleName: "$(PRODUCT_NAME)", + CFBundlePackageType: "XPC!", + CFBundleShortVersionString: "$(MARKETING_VERSION)", + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)", + OpenMapXIntakeAppGroup: `group.${appId}.intake`, + OpenMapXTheme: THEME_COLORS, + OpenMapXCornerRadius: THEME_CORNER_RADIUS, + UTImportedTypeDeclarations: intakeImportedTypes(), + NSExtension: { + NSExtensionPointIdentifier: "com.apple.share-services", + NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController", + NSExtensionAttributes: { NSExtensionActivationRule: rule }, + }, + }; +} +export function shareBuildSettings( + host: Record, + appId: string, +): Record { + return { + PRODUCT_BUNDLE_IDENTIFIER: `"${appId}.ShareExtension"`, + PRODUCT_NAME: `"${TARGET}"`, + INFOPLIST_FILE: `"${TARGET}/Info.plist"`, + CODE_SIGN_ENTITLEMENTS: `"${TARGET}/ShareExtension.entitlements"`, + IPHONEOS_DEPLOYMENT_TARGET: "16.4", + CURRENT_PROJECT_VERSION: host.CURRENT_PROJECT_VERSION ?? "1", + MARKETING_VERSION: host.MARKETING_VERSION ?? "1.0.0", + ...(host.DEVELOPMENT_TEAM ? { DEVELOPMENT_TEAM: host.DEVELOPMENT_TEAM } : {}), + CODE_SIGN_STYLE: "Automatic", + SWIFT_VERSION: "5.0", + APPLICATION_EXTENSION_API_ONLY: "YES", + SKIP_INSTALL: "YES", + TARGETED_DEVICE_FAMILY: '"1"', + LD_RUNPATH_SEARCH_PATHS: + '"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"', + GENERATE_INFOPLIST_FILE: "NO", + }; +} + +export const INTAKE_RECEIVER = "expo.modules.openmapxintake.IntakeReceiverActivity"; +export function intakeReceiverDeclaration() { + return { + $: { + "android:name": INTAKE_RECEIVER, + "android:exported": "true" as const, + "android:noHistory": "true", + "android:excludeFromRecents": "true", + "android:taskAffinity": "", + "android:launchMode": "standard", + "android:theme": "@android:style/Theme.Translucent.NoTitleBar", + }, + "intent-filter": intakeAndroidFilters().map((f) => ({ + action: [{ $: { "android:name": `android.intent.action.${f.action}` } }], + category: f.category.map((c) => ({ $: { "android:name": `android.intent.category.${c}` } })), + data: f.data.map((d) => ({ + $: { "android:mimeType": d.mimeType, ...(d.scheme ? { "android:scheme": d.scheme } : {}) }, + })), + })), + }; +} diff --git a/apps/mobile/modules/openmapx-intake/Package.swift b/apps/mobile/modules/openmapx-intake/Package.swift new file mode 100644 index 000000000..c2f195cba --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/Package.swift @@ -0,0 +1,6 @@ +// swift-tools-version:5.9 +import PackageDescription +let package = Package(name: "IntakeInbox", platforms: [.macOS(.v13)], targets: [ + .target(name: "IntakeInbox", path: "ios", exclude: ["Tests", "Share", "IntakeContainer.swift", "IntakeAppDelegateSubscriber.swift", "OpenMapXIntakeModule.swift", "OpenMapXIntake.podspec", "PrivacyInfo.xcprivacy"], sources: ["IntakePolicy.swift", "IntakeInbox.swift", "ShareSelection.swift", "IntakeExternalDocument.swift"]), + .testTarget(name: "IntakeInboxTests", dependencies: ["IntakeInbox"], path: "ios/Tests") +]) diff --git a/apps/mobile/modules/openmapx-intake/android/build.gradle b/apps/mobile/modules/openmapx-intake/android/build.gradle new file mode 100644 index 000000000..7f7c32d8f --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} +group = 'org.openmapx.intake' +version = '1.0.0' +android { + namespace "expo.modules.openmapxintake" + defaultConfig { versionCode 1; versionName "1.0.0" } + testOptions { unitTests { returnDefaultValues = false; includeAndroidResources = true } } +} +dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/AndroidManifest.xml b/apps/mobile/modules/openmapx-intake/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..94cbbcfc3 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IncomingIntent.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IncomingIntent.kt new file mode 100644 index 000000000..4f2e28c1a --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IncomingIntent.kt @@ -0,0 +1,37 @@ +package expo.modules.openmapxintake + +import android.content.Intent +import android.net.Uri +import android.os.Bundle + +object IncomingIntent { + fun shouldCapture(intent: Intent, savedState: Bundle?): Boolean { + if (savedState != null || intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return false + return when (intent.action) { + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> true + Intent.ACTION_VIEW -> intent.data?.scheme == "content" + else -> false + } + } + @Suppress("DEPRECATION") + fun attachments(intent: Intent): List { + val streams = when (intent.action) { + Intent.ACTION_VIEW -> listOfNotNull(intent.data) + Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM).orEmpty() + Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtra(Intent.EXTRA_STREAM)) + else -> emptyList() + } + // Rich text previews use ClipData for thumbnails. Only stream extras declare files. + if (intent.action == Intent.ACTION_SEND && streams.isEmpty()) return emptyList() + if (streams.size > 5) throw IntakeFailure("too-many") + val uris = streams.toMutableList() + if (streams.isNotEmpty() && intent.action == Intent.ACTION_SEND_MULTIPLE) intent.clipData?.let { clip -> + if (clip.itemCount > 5) throw IntakeFailure("too-many") + for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { if (it !in uris) uris += it } + } + if (uris.size > 5) throw IntakeFailure("too-many") + return uris + } + fun isForeignContent(uri: Uri, ownAuthorities: Set, resolvedUid: Int?, ownUid: Int): Boolean = + uri.scheme == "content" && !uri.authority.isNullOrEmpty() && uri.authority !in ownAuthorities && resolvedUid != ownUid +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeInbox.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeInbox.kt new file mode 100644 index 000000000..131ff59c3 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeInbox.kt @@ -0,0 +1,120 @@ +package expo.modules.openmapxintake + +import android.util.Base64 +import java.io.File +import java.io.InputStream +import java.io.RandomAccessFile +import java.util.UUID +import org.json.JSONObject + +data class IntakeEntry(val id: String, val createdAtMs: Long, val kind: String, val name: String, val byteLength: Int, val format: String? = null, val errorCode: String? = null) { + fun map(): Map = mutableMapOf("id" to id, "createdAtMs" to createdAtMs, "kind" to kind, "name" to name, "byteLength" to byteLength).also { + if (format != null) it["format"] = format + if (errorCode != null) it["errorCode"] = errorCode + } +} +data class IntakeChunk(val id: String, val offset: Int, val data: String, val byteLength: Int, val eof: Boolean) { + fun map() = mapOf("id" to id, "offset" to offset, "data" to data, "byteLength" to byteLength, "eof" to eof) +} +/** Only generated UUIDs address files. All instances share one process-wide commit lock. */ +class IntakeInbox(directory: File, private val now: () -> Long = { System.currentTimeMillis() }) { + private val root = directory.canonicalFile.also { it.mkdirs() } + companion object { private val lock = Any() } + private fun entryDir(id: String): File { + if (!IntakePolicy.ids.matches(id)) throw IntakeFailure("unreadable") + return File(root, id) + } + private fun entries(): List { + val result = mutableListOf() + for (dir in root.listFiles().orEmpty()) { + if (!IntakePolicy.ids.matches(dir.name) || !dir.isDirectory || dir.canonicalFile != dir.absoluteFile) continue + try { + val meta = File(dir, "metadata.json") + if (meta.length() > 4096 || meta.canonicalFile != meta.absoluteFile) throw IntakeFailure("invalid-data") + val obj = JSONObject(meta.readText()) + val kind = obj.getString("kind") + val keys = setOf("id", "createdAtMs", "kind", "name", "byteLength") + when (kind) { "file" -> setOf("format"); "error" -> setOf("errorCode"); "text" -> emptySet(); else -> throw IntakeFailure("invalid-data") } + if (obj.keys().asSequence().toSet() != keys || obj.getString("id") != dir.name) throw IntakeFailure("invalid-data") + val created = obj.getLong("createdAtMs"); val size = obj.getInt("byteLength"); val name = obj.getString("name") + if (created < 0 || now() - created >= IntakePolicy.TTL_MS || name != IntakePolicy.name(name)) throw IntakeFailure("expired") + val format = if (kind == "file") obj.getString("format") else null + val code = if (kind == "error") obj.getString("errorCode") else null + if (kind == "error") { + if (size != 0 || code !in IntakePolicy.errors) throw IntakeFailure("invalid-data") + } else { + val data = File(dir, "data") + val limit = if (kind == "text") IntakePolicy.TEXT_BYTES else IntakePolicy.FILE_BYTES + if (size !in 1..limit || !data.isFile || data.length() != size.toLong() || data.canonicalFile != data.absoluteFile) throw IntakeFailure("invalid-data") + if (kind == "file" && (format != IntakePolicy.format(name))) throw IntakeFailure("invalid-data") + } + result += IntakeEntry(dir.name, created, kind, name, size, format, code) + } catch (_: Exception) { dir.deleteRecursively() } + } + val stage = File(root, "staging") + for (file in stage.listFiles().orEmpty()) { + val epoch = file.name.substringBefore('-').toLongOrNull() ?: continue + if (now() - epoch > IntakePolicy.COPY_TIMEOUT_MS + 60000) file.deleteRecursively() + } + return result.sortedWith(compareBy { it.createdAtMs }.thenBy { it.id }) + } + fun list(): List = synchronized(lock) { entries() } + fun putText(text: String, name: String = "Shared location"): IntakeEntry { + val bytes = text.toByteArray(Charsets.UTF_8) + if (text.length > IntakePolicy.TEXT_LENGTH || bytes.size > IntakePolicy.TEXT_BYTES) throw IntakeFailure("too-large") + if (text.isBlank()) throw IntakeFailure("unsupported") + return copy("text", IntakePolicy.name(name), null, bytes.inputStream(), IntakePolicy.TEXT_BYTES) { false } + } + fun putFile(name: String, source: InputStream, cancelled: () -> Boolean = { false }): IntakeEntry { + val display = IntakePolicy.name(name) + return copy("file", display, IntakePolicy.format(display), source, IntakePolicy.FILE_BYTES, cancelled) + } + private fun copy(kind: String, name: String, format: String?, source: InputStream, limit: Int, cancelled: () -> Boolean): IntakeEntry { + val stage = File(File(root, "staging").also { it.mkdirs() }, "${now()}-${UUID.randomUUID()}") + stage.mkdirs() + var size = 0 + try { + File(stage, "data").outputStream().use { output -> + source.use { input -> + val buffer = ByteArray(8192) + while (true) { + if (cancelled()) throw IntakeFailure("unreadable") + val count = input.read(buffer) + if (count < 0) break + if (count == 0) continue + size += count + if (size > limit) throw IntakeFailure("too-large") + output.write(buffer, 0, count) + } + } + output.fd.sync() + } + if (size == 0) throw IntakeFailure("invalid-data") + return synchronized(lock) { + if (cancelled()) throw IntakeFailure("unreadable") + val current = entries().filter { it.kind != "error" } + if (current.size >= IntakePolicy.CONTENT_ITEMS || current.sumOf { it.byteLength.toLong() } + size > IntakePolicy.INBOX_BYTES) throw IntakeFailure("inbox-full") + val entry = IntakeEntry(UUID.randomUUID().toString(), now(), kind, name, size, format) + File(stage, "metadata.json").writeText(JSONObject(entry.map()).toString()) + if (!stage.renameTo(entryDir(entry.id))) throw IntakeFailure("unreadable") + entry + } + } finally { stage.deleteRecursively() } + } + fun recordError(code: String) = synchronized(lock) { + val errors = entries().filter { it.kind == "error" } + for (old in errors.take((errors.size - IntakePolicy.ERROR_ITEMS + 1).coerceAtLeast(0))) entryDir(old.id).deleteRecursively() + val entry = IntakeEntry(UUID.randomUUID().toString(), now(), "error", "OpenMapX", 0, errorCode = code.takeIf { it in IntakePolicy.errors } ?: "unreadable") + val dir = entryDir(entry.id).also { it.mkdirs() } + File(dir, "metadata.json").writeText(JSONObject(entry.map()).toString()) + } + fun readChunk(id: String, offset: Int): IntakeChunk = synchronized(lock) { + if (offset < 0 || offset % IntakePolicy.CHUNK_BYTES != 0) throw IntakeFailure("unreadable") + val dir = entryDir(id) + val entry = entries().find { it.id == id && it.kind != "error" } ?: throw IntakeFailure("expired") + if (offset >= entry.byteLength) throw IntakeFailure("unreadable") + val bytes = ByteArray(minOf(IntakePolicy.CHUNK_BYTES, entry.byteLength - offset)) + RandomAccessFile(File(dir, "data"), "r").use { it.seek(offset.toLong()); it.readFully(bytes) } + IntakeChunk(id, offset, Base64.encodeToString(bytes, Base64.NO_WRAP), bytes.size, offset + bytes.size == entry.byteLength) + } + fun complete(id: String) = synchronized(lock) { entryDir(id).deleteRecursively(); Unit } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt new file mode 100644 index 000000000..d848d5f6d --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt @@ -0,0 +1,84 @@ +package expo.modules.openmapxintake + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import java.io.File +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** Pre-JS capture. No Activity, sender URI, or content survives in the event payload. */ +object IntakeIngress { + val listeners = CopyOnWriteArraySet<() -> Unit>() + private val workers = ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS, ArrayBlockingQueue(8)) + private val deadlines = Executors.newSingleThreadScheduledExecutor() + private val overflowWorker = Executors.newSingleThreadExecutor() + private val overflowPending = AtomicBoolean(false) + fun inbox(context: Context) = IntakeInbox(File(context.noBackupFilesDir, "openmapx-intake")) + internal fun changed() { listeners.forEach { try { it() } catch (_: Exception) {} } } + fun capture(context: Context, intent: Intent) { + val app = context.applicationContext + // Copy while grants are attached to the receiving Activity. The queue is deliberately bounded. + try { workers.execute { try { ingest(app, intent) } catch (_: Exception) { changed() } } } + catch (_: Exception) { reportError(app, "inbox-full") } + } + internal fun reportError(context: Context, code: String) { + // The Activity thread can report an error but never does quota/filesystem work. + val app = context.applicationContext + if (overflowPending.compareAndSet(false, true)) overflowWorker.execute { + try { inbox(app).recordError(code) } catch (_: Exception) {} + finally { overflowPending.set(false); changed() } + } + } + @Suppress("DEPRECATION") + private fun ingest(context: Context, intent: Intent) { + val inbox = inbox(context) + val cancelled = AtomicBoolean(false) + val signal = CancellationSignal() + val descriptor = AtomicReference(null) + val started = System.nanoTime() + val deadline = deadlines.schedule({ cancelled.set(true); try { descriptor.get()?.close() } catch (_: Exception) {}; try { signal.cancel() } catch (_: Exception) {} }, IntakePolicy.COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS) + try { + val uris = IncomingIntent.attachments(intent) + if (uris.isEmpty()) { + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: throw IntakeFailure("unsupported") + if (text.length > IntakePolicy.TEXT_LENGTH) throw IntakeFailure("too-large") + if (cancelled.get()) throw IntakeFailure("unreadable") + inbox.putText(text.toString()) + } else { + val manager = context.packageManager + val ownAuthorities = manager.getPackageInfo(context.packageName, PackageManager.GET_PROVIDERS).providers.orEmpty().flatMap { it.authority.orEmpty().split(';') }.toSet() + // Preflight every attachment before committing any; mixed unsupported shares are rejected. + val files = uris.map { uri -> + val resolved = manager.resolveContentProvider(uri.authority.orEmpty(), 0) + if (!IncomingIntent.isForeignContent(uri, ownAuthorities, resolved?.applicationInfo?.uid, android.os.Process.myUid())) throw IntakeFailure("unsupported") + val name = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null, signal)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } ?: throw IntakeFailure("unreadable") + if (name.length > 1024) throw IntakeFailure("unsupported") + val display = IntakePolicy.name(name) + IntakePolicy.format(display) + uri to display + } + for ((uri, name) in files) { + if (cancelled.get()) throw IntakeFailure("unreadable") + val fd = context.contentResolver.openFileDescriptor(uri, "r", signal) ?: throw IntakeFailure("unreadable") + descriptor.set(fd) + val remaining = IntakePolicy.COPY_TIMEOUT_MS - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + if (cancelled.get() || remaining <= 0) { fd.close(); throw IntakeFailure("unreadable") } + ProviderReader.copy(name, fd, inbox, remaining) + descriptor.set(null) + } + } + } catch (e: Exception) { try { inbox.recordError((e as? IntakeFailure)?.code ?: "unreadable") } catch (_: Exception) {} } + finally { deadline.cancel(false); try { descriptor.get()?.close() } catch (_: Exception) {}; changed() } + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePolicy.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePolicy.kt new file mode 100644 index 000000000..47ef170a1 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePolicy.kt @@ -0,0 +1,23 @@ +package expo.modules.openmapxintake + +import java.util.Locale + +class IntakeFailure(val code: String) : Exception(code) +object IntakePolicy { + const val FILE_BYTES = 5 * 1024 * 1024 + const val TEXT_BYTES = 8192 + const val TEXT_LENGTH = 4096 + const val CHUNK_BYTES = 24576 + const val INBOX_BYTES = 20 * 1024 * 1024 + const val CONTENT_ITEMS = 8 + const val ERROR_ITEMS = 2 + const val TTL_MS = 24 * 60 * 60 * 1000L + const val COPY_TIMEOUT_MS = 10000L + val ids = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + val errors = setOf("unsupported", "too-large", "too-many", "inbox-full", "unreadable", "expired", "unavailable", "invalid-data") + fun name(raw: String) = raw.substringAfterLast('/').substringAfterLast('\\').filter { it.code >= 32 && it.code !in 0x202A..0x202E && it.code !in 0x2066..0x2069 }.take(128).trim() + fun format(name: String): String = when (name.substringAfterLast('.', "").lowercase(Locale.ROOT)) { + "gpx" -> "gpx"; "geojson" -> "geojson"; "kml" -> "kml" + else -> throw IntakeFailure("unsupported") + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeReceiverActivity.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeReceiverActivity.kt new file mode 100644 index 000000000..e15d2d42b --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeReceiverActivity.kt @@ -0,0 +1,50 @@ +package expo.modules.openmapxintake + +import android.app.Activity +import android.content.ClipData +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.Bundle + +/** No history/task affinity: the React task always keeps a neutral MAIN root intent. */ +open class IntakeReceiverActivity : Activity() { + protected open fun mainComponent(): ComponentName? = packageManager.getLaunchIntentForPackage(packageName)?.component + protected open fun enqueue(source: Intent) { IntakeIngress.capture(this, source) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + receive(intent, savedInstanceState) + } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + receive(intent, null) + } + private fun receive(source: Intent, savedState: Bundle?) { + try { + if (!IncomingIntent.shouldCapture(source, savedState)) return + val component = mainComponent() ?: return + val grants = try { IncomingIntent.attachments(source).filter { it.scheme == "content" } } catch (_: Exception) { emptyList() } + // Create/restore the singleTask host with a clean task root, even on a cold share. + // Deliver grants separately through onNewIntent so Recents never replays them. + // Never forward source actions, extras, data, write or persistable authority. + startActivity(neutralMainIntent(component, emptyList(), 0)) + if (grants.isNotEmpty() && source.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) { + startActivity(neutralMainIntent(component, grants, source.flags)) + } + enqueue(Intent(source)) + } catch (_: Exception) { + IntakeIngress.reportError(this, "unreadable") + // A refused grant must not crash a running trip. Try opening only the host. + try { mainComponent()?.let { startActivity(neutralMainIntent(it, emptyList(), 0)) } } catch (_: Exception) {} + } finally { finish() } + } +} + +internal fun neutralMainIntent(component: ComponentName, grants: List, sourceFlags: Int): Intent = + Intent(Intent.ACTION_MAIN).setComponent(component).addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP).apply { + if (grants.isNotEmpty() && sourceFlags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) { + clipData = ClipData.newRawUri("OpenMapX intake", grants.first()).also { clip -> grants.drop(1).forEach { clip.addItem(ClipData.Item(it)) } } + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/OpenMapXIntakeModule.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/OpenMapXIntakeModule.kt new file mode 100644 index 000000000..0a8276510 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/OpenMapXIntakeModule.kt @@ -0,0 +1,20 @@ +package expo.modules.openmapxintake + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class OpenMapXIntakeModule : Module() { + private val changed: () -> Unit = { sendEvent("onInboxChanged", emptyMap()) } + private fun inbox() = IntakeIngress.inbox(appContext.reactContext ?: throw IntakeFailure("unavailable")) + private fun result(work: () -> Map): Map = try { work() } catch (e: Exception) { mapOf("errorCode" to ((e as? IntakeFailure)?.code ?: "unavailable")) } + override fun definition() = ModuleDefinition { + Name("OpenMapXIntake") + Events("onInboxChanged") + OnCreate { IntakeIngress.listeners.add(changed) } + OnDestroy { IntakeIngress.listeners.remove(changed) } + AsyncFunction("isAvailableAsync") { true } + AsyncFunction("listAsync") { result { mapOf("items" to inbox().list().map { it.map() }) } } + AsyncFunction("readChunkAsync") { id: String, offset: Int -> result { inbox().readChunk(id, offset).map() } } + AsyncFunction("completeAsync") { id: String -> result { inbox().complete(id); mapOf("ok" to true) } } + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/ProviderReader.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/ProviderReader.kt new file mode 100644 index 000000000..00ec9dfe6 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/ProviderReader.kt @@ -0,0 +1,54 @@ +package expo.modules.openmapxintake + +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.system.StructPollfd +import java.io.InputStream +import java.io.FileDescriptor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** Poll + nonblocking reads: a provider's pipe cannot pin an ingestion worker forever. */ +object ProviderReader { + private val watchdog = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "OpenMapX-intake-deadline").apply { isDaemon = true } } + interface Io { + fun configure(fd: FileDescriptor) + fun ready(fd: FileDescriptor): Boolean + fun read(fd: FileDescriptor, data: ByteArray, offset: Int, length: Int): Int + } + private val platformIo = object : Io { + override fun configure(fd: FileDescriptor) { Os.fcntlInt(fd, OsConstants.F_SETFL, Os.fcntlInt(fd, OsConstants.F_GETFL, 0) or OsConstants.O_NONBLOCK) } + override fun ready(fd: FileDescriptor): Boolean { + val poll = StructPollfd().apply { this.fd = fd; events = OsConstants.POLLIN.toShort() } + return Os.poll(arrayOf(poll), 50) > 0 + } + override fun read(fd: FileDescriptor, data: ByteArray, offset: Int, length: Int) = Os.read(fd, data, offset, length) + } + fun copy(name: String, descriptor: ParcelFileDescriptor, inbox: IntakeInbox, timeoutMs: Long = IntakePolicy.COPY_TIMEOUT_MS, io: Io = platformIo): IntakeEntry { + val expired = AtomicBoolean(false) + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs) + val task = watchdog.schedule({ expired.set(true); try { descriptor.close() } catch (_: Exception) {} }, timeoutMs, TimeUnit.MILLISECONDS) + try { + val fd = descriptor.fileDescriptor + io.configure(fd) + val source = object : InputStream() { + override fun read(): Int { val data = ByteArray(1); return if (read(data, 0, 1) < 0) -1 else data[0].toInt() and 255 } + override fun read(data: ByteArray, offset: Int, length: Int): Int { + while (!expired.get() && System.nanoTime() < deadline) { + if (!io.ready(fd)) continue + try { val count = io.read(fd, data, offset, length); return if (count == 0) -1 else count } + catch (e: ErrnoException) { if (e.errno != OsConstants.EAGAIN && e.errno != OsConstants.EINTR) throw e } + } + throw IntakeFailure("unreadable") + } + override fun close() { descriptor.close() } + } + return inbox.putFile(name, source) { expired.get() || System.nanoTime() >= deadline } + } catch (e: IntakeFailure) { throw e } + catch (_: Exception) { throw IntakeFailure("unreadable") } + finally { task.cancel(false); try { descriptor.close() } catch (_: Exception) {} } + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IncomingIntentTest.kt b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IncomingIntentTest.kt new file mode 100644 index 000000000..b40782974 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IncomingIntentTest.kt @@ -0,0 +1,38 @@ +package expo.modules.openmapxintake + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class IncomingIntentTest { + @Test fun rejectsHistoryAndSavedStateButAcceptsFreshShare() { + val intent = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "Station") + assertTrue(IncomingIntent.shouldCapture(intent, null)) + assertFalse(IncomingIntent.shouldCapture(intent, Bundle())) + assertFalse(IncomingIntent.shouldCapture(Intent(intent).addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY), null)) + } + @Test fun leavesMapAndNativeEndIntentsAlone() { + assertFalse(IncomingIntent.shouldCapture(Intent(Intent.ACTION_VIEW, Uri.parse("geo:50,8")), null)) + assertFalse(IncomingIntent.shouldCapture(Intent(Intent.ACTION_VIEW, Uri.parse("openmapx:///navigation/end?sessionId=old")), null)) + } + @Test fun singleFileSharingIgnoresAnAdditionalPreviewThumbnail() { + val uri = Uri.parse("content://foreign.provider/route.gpx") + val share = Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uri).apply { + clipData = android.content.ClipData.newRawUri("preview", Uri.parse("content://foreign.provider/preview.png")) + } + assertEquals(listOf(uri), IncomingIntent.attachments(share)) + } + @Test fun fileInputMustBeForeignContentAndVisibilityNullDoesNotRefuseIt() { + assertTrue(IncomingIntent.isForeignContent(Uri.parse("content://foreign.provider/file"), setOf("our.provider"), null, 123)) + assertFalse(IncomingIntent.isForeignContent(Uri.parse("content://our.provider/file"), setOf("our.provider"), null, 123)) + assertFalse(IncomingIntent.isForeignContent(Uri.parse("content://alias.provider/file"), emptySet(), 123, 123)) + assertFalse(IncomingIntent.isForeignContent(Uri.parse("file:///private/route.gpx"), emptySet(), null, 123)) + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeInboxTest.kt b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeInboxTest.kt new file mode 100644 index 000000000..ae2f41080 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeInboxTest.kt @@ -0,0 +1,82 @@ +package expo.modules.openmapxintake + +import java.io.ByteArrayInputStream +import java.io.File +import java.util.concurrent.Executors +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class IntakeInboxTest { + @get:Rule val temp = TemporaryFolder() + private var clock = 1_700_000_000_000L + private fun inbox() = IntakeInbox(temp.newFolder(), { clock }) + private fun failure(code: String, work: () -> Unit) { + try { work(); fail("expected $code") } catch (e: IntakeFailure) { assertEquals(code, e.code) } + } + @Test fun textSurvivesReopenAndAcknowledgementIsIdempotent() { + val dir = temp.newFolder() + val store = IntakeInbox(dir) { clock } + val entry = store.putText("Bahnhofstraße München") + assertEquals("text", entry.kind) + val reopened = IntakeInbox(dir) { clock } + assertEquals(entry.id, reopened.list().single().id) + val chunk = reopened.readChunk(entry.id, 0) + assertTrue(chunk.eof) + assertArrayEquals("Bahnhofstraße München".toByteArray(), android.util.Base64.decode(chunk.data, android.util.Base64.DEFAULT)) + reopened.complete(entry.id); reopened.complete(entry.id) + assertTrue(reopened.list().isEmpty()) + } + @Test fun rejectsUnknownExtensionAndOversizedActualStream() { + val store = inbox() + failure("unsupported") { store.putFile("private.json", ByteArrayInputStream("{}".toByteArray())) } + failure("too-large") { store.putFile("route.gpx", ByteArrayInputStream(ByteArray(IntakePolicy.FILE_BYTES + 1))) } + assertTrue(store.list().isEmpty()) + } + @Test fun expiryRemovesDataAndOpaqueIdsCannotReadOtherFiles() { + val store = inbox(); val item = store.putText("Station") + failure("unreadable") { store.readChunk("../private", 0) } + failure("unreadable") { store.readChunk(item.id, 1) } + clock += IntakePolicy.TTL_MS + 1 + assertTrue(store.list().isEmpty()) + failure("expired") { store.readChunk(item.id, 0) } + } + @Test fun fullInboxStillReportsASeparateBoundedError() { + val store = inbox() + repeat(8) { store.putText("Station $it") } + failure("inbox-full") { store.putText("Ninth item") } + repeat(5) { store.recordError("inbox-full") } + assertEquals(8, store.list().count { it.kind != "error" }) + assertEquals(2, store.list().count { it.kind == "error" }) + } + @Test fun concurrentCommitsCannotExceedQuota() { + val dir = temp.newFolder(); val pool = Executors.newFixedThreadPool(4) + try { + val jobs = (0..15).map { n -> pool.submit { + try { IntakeInbox(dir) { clock }.putText("Station $n"); true } catch (_: IntakeFailure) { false } + } } + assertEquals(8, jobs.count { it.get() }) + assertEquals(8, IntakeInbox(dir) { clock }.list().size) + } finally { pool.shutdownNow() } + } + @Test fun chunksAreStatelessAndDoNotExceedTheWireLimit() { + val store = inbox(); val data = ByteArray(50000) { (it % 127).toByte() } + val item = store.putFile("route.geojson", ByteArrayInputStream(data)) + val first = store.readChunk(item.id, 0); val second = store.readChunk(item.id, IntakePolicy.CHUNK_BYTES) + assertEquals(24576, first.byteLength); assertFalse(first.eof) + assertEquals(24576, second.byteLength); assertFalse(second.eof) + val last = store.readChunk(item.id, 49152); assertTrue(last.eof) + assertEquals(848, last.byteLength) + } + @Test fun cancellationNeverCommitsAPartialFile() { + val store = inbox() + failure("unreadable") { store.putFile("route.kml", ByteArrayInputStream(ByteArray(20))) { true } } + assertTrue(store.list().isEmpty()) + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeIngressTest.kt b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeIngressTest.kt new file mode 100644 index 000000000..2e12d5846 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeIngressTest.kt @@ -0,0 +1,13 @@ +package expo.modules.openmapxintake +import org.junit.Assert.assertTrue +import org.junit.Test +class IntakeIngressTest { + @Test fun aDisposedModuleListenerCannotCrashTheReceiverOrSuppressOtherListeners() { + var delivered = false + val disposed: () -> Unit = { throw IllegalStateException("disposed runtime") } + val live: () -> Unit = { delivered = true } + IntakeIngress.listeners.add(disposed); IntakeIngress.listeners.add(live) + try { IntakeIngress.changed(); assertTrue(delivered) } + finally { IntakeIngress.listeners.remove(disposed); IntakeIngress.listeners.remove(live) } + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeReceiverActivityTest.kt b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeReceiverActivityTest.kt new file mode 100644 index 000000000..ee8ffddbe --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeReceiverActivityTest.kt @@ -0,0 +1,66 @@ +package expo.modules.openmapxintake +import android.content.ComponentName +import android.content.Intent +import android.content.ClipData +import android.net.Uri +import android.os.Bundle +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class IntakeReceiverActivityTest { + class Receiver : IntakeReceiverActivity() { + val received = mutableListOf() + val launches = mutableListOf() + override fun startActivity(intent: Intent) { launches += Intent(intent); super.startActivity(intent) } + override fun mainComponent() = ComponentName("test.app", "test.app.MainActivity") + override fun enqueue(source: Intent) { received += source; assertNotNull(shadowOf(this).peekNextStartedActivity()) } + } + private fun text() = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "Station") + @Test fun mainTaskNeverStoresAReplayableShareEvenWhenExpoDiscardsItsBundle() { + val receiver = Robolectric.buildActivity(Receiver::class.java, text()).create().get() + assertEquals(1, receiver.received.size) + val main = shadowOf(receiver).nextStartedActivity + assertEquals(Intent.ACTION_MAIN, main.action) + assertFalse(IncomingIntent.shouldCapture(main, null)) // Expo's actual super.onCreate(null) path. + assertFalse(main.hasExtra(Intent.EXTRA_TEXT)); assertTrue(receiver.isFinishing) + } + @Test fun forwardsReadOnlyGrantsForTheHostTaskBeforeCopyStarts() { + val uri = Uri.parse("content://foreign/route.gpx") + val share = Intent(Intent.ACTION_SEND).setType("application/gpx+xml").putExtra(Intent.EXTRA_STREAM, uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val receiver = Robolectric.buildActivity(Receiver::class.java, share).create().get() + assertEquals(2, receiver.launches.size) + val root = receiver.launches[0] + assertEquals(Intent.ACTION_MAIN, root.action) + assertNull(root.clipData) + assertEquals(0, root.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)) + val main = receiver.launches[1] + assertEquals(uri, main.clipData!!.getItemAt(0).uri) + assertTrue(main.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertEquals(0, main.flags and (Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)) + assertNull(main.data) + } + @Test fun ignoresRestorationAndAllowsDeliberatelySharingTheSameContentAgain() { + val restored = Robolectric.buildActivity(Receiver::class.java, text()).create(Bundle()).get() + assertTrue(restored.received.isEmpty()) + val first = Robolectric.buildActivity(Receiver::class.java, text()).create().get() + val second = Robolectric.buildActivity(Receiver::class.java, text()).create().get() + assertEquals(1, first.received.size); assertEquals(1, second.received.size) + } + @Test fun richPreviewThumbnailDoesNotTurnTextIntoAnImageAttachment() { + val share = text().apply { clipData = ClipData.newRawUri("preview", Uri.parse("content://foreign/thumbnail.png")) } + assertTrue(IncomingIntent.attachments(share).isEmpty()) + val receiver = Robolectric.buildActivity(Receiver::class.java, share).create().get() + assertNull(shadowOf(receiver).nextStartedActivity.clipData) + val folder = java.nio.file.Files.createTempDirectory("intake-preview").toFile() + val inbox = IntakeInbox(folder) + try { val entry = inbox.putText(receiver.received.single().getStringExtra(Intent.EXTRA_TEXT)!!); assertEquals("text", entry.kind) } + finally { folder.deleteRecursively() } + } +} diff --git a/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/ProviderReaderTest.kt b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/ProviderReaderTest.kt new file mode 100644 index 000000000..52c7e9729 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/ProviderReaderTest.kt @@ -0,0 +1,49 @@ +package expo.modules.openmapxintake + +import android.os.ParcelFileDescriptor +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ProviderReaderTest { + @get:Rule val temp = TemporaryFolder() + // Robolectric implements createPipe using a temp file and has no Os.poll/read shadow. + // Exercise the real descriptor + store with an injected polling adapter; actual pipes are a device gate. + private fun io(hung: Boolean = false) = object : ProviderReader.Io { + override fun configure(fd: FileDescriptor) {} + override fun ready(fd: FileDescriptor): Boolean { if (hung) Thread.sleep(5); return !hung } + override fun read(fd: FileDescriptor, data: ByteArray, offset: Int, length: Int): Int = FileInputStream(fd).read(data, offset, length).let { if (it < 0) 0 else it } + } + @Test fun acceptsReadableDescriptor() { + val pipe = ParcelFileDescriptor.createPipe() + ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]).use { it.write("".toByteArray()) } + val inbox = IntakeInbox(temp.newFolder()) + val entry = ProviderReader.copy("route.gpx", pipe[0], inbox, io = io()) + assertEquals(6, entry.byteLength) + } + @Test fun deadlineClosesHungPipeAndDoesNotCommit() { + val pipe = ParcelFileDescriptor.createPipe() + val inbox = IntakeInbox(temp.newFolder()) + val executor = Executors.newSingleThreadExecutor() + try { + val result = executor.submit { + try { ProviderReader.copy("route.gpx", pipe[0], inbox, 50, io(true)); "unexpected" } + catch (e: IntakeFailure) { e.code } + } + assertEquals("unreadable", result.get(3, TimeUnit.SECONDS)) + assertTrue(inbox.list().isEmpty()) + assertFalse(pipe[0].fileDescriptor.valid()) + } finally { pipe[1].close(); executor.shutdownNow() } + } +} diff --git a/apps/mobile/modules/openmapx-intake/expo-module.config.json b/apps/mobile/modules/openmapx-intake/expo-module.config.json new file mode 100644 index 000000000..1ca556436 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/expo-module.config.json @@ -0,0 +1,10 @@ +{ + "platforms": ["android", "apple"], + "android": { + "modules": ["expo.modules.openmapxintake.OpenMapXIntakeModule"] + }, + "apple": { + "modules": ["OpenMapXIntakeModule"], + "appDelegateSubscribers": ["IntakeAppDelegateSubscriber"] + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift new file mode 100644 index 000000000..39e93c33e --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift @@ -0,0 +1,88 @@ +import ExpoModulesCore +import Foundation +import UIKit + +private final class HostDocumentCancellation { + private let lock = NSLock() + private var cancelled = false, action: (() -> Void)? + var isCancelled: Bool { lock.lock(); defer { lock.unlock() }; return cancelled } + func install(_ action: (() -> Void)?) { + lock.lock(); let cancel = cancelled; if !cancel { self.action = action }; lock.unlock() + if cancel { action?() } + } + func cancel() { + lock.lock(); guard !cancelled else { lock.unlock(); return } + cancelled = true; let action = self.action; self.action = nil; lock.unlock(); action?() + } +} +private final class HostDocumentResult { + private let lock = NSLock() + private var value: Result? + func set(_ value: Result) { lock.lock(); self.value = value; lock.unlock() } + func get() -> Result { lock.lock(); defer { lock.unlock() }; return value ?? .failure(IntakeFailure.unreadable) } +} + +public final class IntakeAppDelegateSubscriber: ExpoAppDelegateSubscriber { + private static let queue = DispatchQueue(label: "org.openmapx.intake.documents", qos: .userInitiated) + public func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + guard url.isFileURL else { return false } + let openInPlace = options[.openInPlace] as? Bool ?? false + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return false } + let inbox = documents.appendingPathComponent("Inbox", isDirectory: true) + let deadline = ProcessInfo.processInfo.systemUptime + 10 + let cancellation = HostDocumentCancellation() + let lifetime = IntakeBackgroundLease(invalid: UIBackgroundTaskIdentifier.invalid, + begin: { expiration in application.beginBackgroundTask(withName: "OpenMapX document intake", expirationHandler: expiration) }, + end: { id in + // Expiration runs on the main thread: end the assertion before returning. + if Thread.isMainThread { application.endBackgroundTask(id) } + else { DispatchQueue.main.async { application.endBackgroundTask(id) } } + }) + lifetime.begin { cancellation.cancel() } + Self.queue.async { + defer { cancellation.install(nil); lifetime.end() } + guard !cancellation.isCancelled else { return } + do { + let store = try IntakeContainer.store(writer: "host") + let register: (NSFileCoordinator) -> Void = { coordinator in cancellation.install { coordinator.cancel() } } + let result: Result + if openInPlace { + let job = IntakeExternalDocument(url: url, store: store, + privateRoots: [URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), try IntakeContainer.root()], + timeout: max(0, deadline - ProcessInfo.processInfo.systemUptime)) + let completed = DispatchSemaphore(value: 0), outcome = HostDocumentResult() + cancellation.install { job.cancel() } + job.start { result in outcome.set(result); completed.signal() } + // Only this dedicated worker waits. The coordinator uses its own + // serial operation queue, and the overall deadline cancels its wait. + if completed.wait(timeout: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) == .timedOut { job.cancel() } + result = outcome.get() + } else { + result = Result { try store.captureDocument(url, osInbox: inbox, openInPlace: false, + deadline: deadline, cancelled: { cancellation.isCancelled }, onCoordinator: register) } + } + cancellation.install(nil) + guard !cancellation.isCancelled else { return } + if case .failure(let error) = result { + try? store.recordError((error as? IntakeFailure)?.rawValue ?? "unreadable", + cancelled: { cancellation.isCancelled }, onCoordinator: register) + } + if !cancellation.isCancelled { NotificationCenter.default.post(name: IntakeContainer.changed, object: nil) } + } catch { /* No group authority: never fall back to the app's private files. */ } + } + return true + } + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + // Only abandoned OS copies are reaped; never delete a provider's original. + Self.queue.async { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let inbox = documents.appendingPathComponent("Inbox") + for file in (try? FileManager.default.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey, .isSymbolicLinkKey])) ?? [] { + guard let values = try? file.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey, .isSymbolicLinkKey]), values.isRegularFile == true, values.isSymbolicLink != true, + let date = values.contentModificationDate, Date().timeIntervalSince(date) > 86400 else { continue } + try? FileManager.default.removeItem(at: file) + } + } + return true + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift new file mode 100644 index 000000000..b0d421e4b --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift @@ -0,0 +1,15 @@ +import Foundation + +/// This group contains only the intake inbox, never credentials or navigation storage. +enum IntakeContainer { + static func root() throws -> URL { + guard let group = Bundle.main.object(forInfoDictionaryKey: "OpenMapXIntakeAppGroup") as? String, + group.hasPrefix("group."), group.hasSuffix(".intake"), + let root = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { throw IntakeFailure.unavailable } + return root + } + static func store(writer: String) throws -> IntakeInbox { + try IntakeInbox(root: root().appendingPathComponent("Inbox", isDirectory: true), writer: writer) + } + static let changed = Notification.Name("OpenMapXIntakeChanged") +} diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeExternalDocument.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeExternalDocument.swift new file mode 100644 index 000000000..27731e48b --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeExternalDocument.swift @@ -0,0 +1,156 @@ +import Foundation + +/// One owner for a platform background assertion, including expiry during begin. +final class IntakeBackgroundLease { + private let invalid: Identifier, beginTask: (@escaping () -> Void) -> Identifier, endTask: (Identifier) -> Void + private let lock = NSLock() + private var identifier: Identifier, ended = false + init(invalid: Identifier, begin: @escaping (@escaping () -> Void) -> Identifier, end: @escaping (Identifier) -> Void) { + self.invalid = invalid; identifier = invalid; beginTask = begin; endTask = end + } + func begin(expiration: @escaping () -> Void) { + let id = beginTask { self.finish(expiration: expiration) } + lock.lock(); let alreadyEnded = ended; if !alreadyEnded { identifier = id }; lock.unlock() + if alreadyEnded && id != invalid { endTask(id) } + } + func end() { finish(expiration: nil) } + private func finish(expiration: (() -> Void)?) { + lock.lock(); guard !ended else { lock.unlock(); return } + ended = true; let id = identifier; identifier = invalid; lock.unlock() + expiration?(); if id != invalid { endTask(id) } + } +} + +protocol IntakeDocumentScope { + func start(_ url: URL) -> Bool + func stop(_ url: URL) +} +protocol IntakeDocumentCoordinating { + func coordinate(_ url: URL, on queue: OperationQueue, accessor: @escaping (Result) -> Void) + func cancel() +} +private struct SystemDocumentScope: IntakeDocumentScope { + func start(_ url: URL) -> Bool { url.startAccessingSecurityScopedResource() } + func stop(_ url: URL) { url.stopAccessingSecurityScopedResource() } +} +private final class SystemDocumentCoordinator: IntakeDocumentCoordinating { + private let coordinator = NSFileCoordinator() + func coordinate(_ url: URL, on queue: OperationQueue, accessor: @escaping (Result) -> Void) { + let intent = NSFileAccessIntent.readingIntent(with: url, options: .withoutChanges) + coordinator.coordinate(with: [intent], queue: queue) { error in + if let error { accessor(.failure(error)) } else { accessor(.success(intent.url)) } + } + } + func cancel() { coordinator.cancel() } +} +private final class DocumentScopeLease { + private let lock = NSLock(), scope: IntakeDocumentScope, url: URL + private var released = true + init(scope: IntakeDocumentScope, url: URL) throws { + self.scope = scope; self.url = url + guard scope.start(url) else { throw IntakeFailure.unreadable } + released = false + } + func release() { + lock.lock(); let release = !released; released = true; lock.unlock() + if release { scope.stop(url) } + } + deinit { release() } +} + +/// Host-only OS URL intake. The external accessor only stages bytes; private +/// inbox coordination starts after it exits. The original is never mutated. +final class IntakeExternalDocument { + private let url: URL, store: IntakeInbox, privateRoots: [String], scope: IntakeDocumentScope + private let coordinator: IntakeDocumentCoordinating, queue = OperationQueue(), lock = NSLock() + private let deadline: TimeInterval + private var started = false, finished = false, cancelled = false + private var completion: ((Result) -> Void)? + private var lease: DocumentScopeLease?, cancelCoordination: (() -> Void)?, timer: DispatchSourceTimer? + + init(url: URL, store: IntakeInbox, privateRoots: [URL], scope: IntakeDocumentScope? = nil, + coordinator: IntakeDocumentCoordinating? = nil, timeout: TimeInterval = 10) { + self.url = url; self.store = store + self.privateRoots = privateRoots.map { Self.normalized(IntakePolicy.canonical($0).path) } + self.scope = scope ?? SystemDocumentScope(); self.coordinator = coordinator ?? SystemDocumentCoordinator() + self.deadline = ProcessInfo.processInfo.systemUptime + timeout + queue.maxConcurrentOperationCount = 1; queue.qualityOfService = .userInitiated + } + private var stopped: Bool { + lock.lock(); defer { lock.unlock() } + return finished || cancelled || ProcessInfo.processInfo.systemUptime >= deadline + } + func start(_ completion: @escaping (Result) -> Void) { + lock.lock() + guard !started else { lock.unlock(); return }; started = true + guard !finished else { lock.unlock(); completion(.failure(IntakeFailure.unreadable)); return } + self.completion = completion; lock.unlock() + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated)) + timer.schedule(deadline: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) + timer.setEventHandler { [weak self] in self?.cancel() }; timer.resume() + lock.lock(); if finished { lock.unlock(); timer.cancel() } else { self.timer = timer; lock.unlock() } + queue.addOperation { self.begin() } + } + func cancel() { finish(.failure(IntakeFailure.unreadable), cancelling: true) } + private func setCancellation(_ action: (() -> Void)?) { + lock.lock(); let cancel = finished || cancelled; if !cancel { cancelCoordination = action }; lock.unlock() + if cancel { action?() } + } + private static func normalized(_ path: String) -> String { + path.hasPrefix("/private/var/") ? String(path.dropFirst(8)) : path + } + private func validate(_ file: URL) throws { + guard file.isFileURL else { throw IntakeFailure.unsupported } + let canonical = Self.normalized(IntakePolicy.canonical(file).path) + let path = file.standardizedFileURL.path + // /var is Apple's system alias, not a user-supplied symlink exception. + let normalized = Self.normalized(path) + guard canonical == normalized, !privateRoots.contains(where: { canonical == $0 || canonical.hasPrefix($0 + "/") }) else { throw IntakeFailure.unsupported } + } + private func begin() { + do { + guard !stopped, url.isFileURL else { throw IntakeFailure.unreadable } + _ = try IntakePolicy.format(IntakePolicy.name(url.lastPathComponent)) + // Acquire on the worker, on the original URL object forwarded by Expo. + let ownedLease = try DocumentScopeLease(scope: scope, url: url) + lock.lock() + if finished { lock.unlock(); ownedLease.release(); return } + lease = ownedLease; lock.unlock() + try validate(url) + setCancellation { [coordinator] in coordinator.cancel() } + guard !stopped else { throw IntakeFailure.unreadable } + coordinator.coordinate(url, on: queue) { result in + let staged = Result { + let coordinatedURL = try result.get() + guard !self.stopped else { throw IntakeFailure.unreadable } + try self.validate(coordinatedURL) + return try self.store.stageFile(coordinatedURL, name: self.url.lastPathComponent, + deadline: self.deadline, cancelled: { self.stopped }, validateOpened: self.validate) + } + // The serial queue cannot execute this until the accessor has exited. + self.queue.addOperation { + ownedLease.release() + self.setCancellation(nil) + let committed = Result { + guard !self.stopped else { throw IntakeFailure.unreadable } + return try self.store.commit(try staged.get(), deadline: self.deadline, + cancelled: { self.stopped }, onCoordinator: { coordinator in + self.setCancellation { coordinator.cancel() } + }) + } + self.finish(committed) + } + } + } catch { finish(.failure(error)) } + } + private func finish(_ result: Result, cancelling: Bool = false) { + lock.lock() + guard !finished else { lock.unlock(); return } + finished = true; cancelled = cancelling + let completion = self.completion, lease = self.lease, timer = self.timer + let cancel = cancelling ? cancelCoordination : nil + self.completion = nil; self.lease = nil; self.timer = nil; cancelCoordination = nil + lock.unlock() + timer?.cancel(); cancel?(); lease?.release(); completion?(result) + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift new file mode 100644 index 000000000..f480e2475 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift @@ -0,0 +1,198 @@ +import Foundation +import Darwin + +struct IntakeEntry: Codable { + let id: String, createdAtMs: Int64, kind: String, name: String, byteLength: Int + var format: String? = nil, errorCode: String? = nil + var dictionary: [String: Any] { + var value: [String: Any] = ["id": id, "createdAtMs": createdAtMs, "kind": kind, "name": name, "byteLength": byteLength] + if let format { value["format"] = format }; if let errorCode { value["errorCode"] = errorCode }; return value + } +} +struct IntakeChunk { + let id: String, offset: Int, data: String, byteLength: Int, eof: Bool + var dictionary: [String: Any] { ["id": id, "offset": offset, "data": data, "byteLength": byteLength, "eof": eof] } +} +/// Immutable owned entries, coordinated across host + extension. External reads never hold the coordination lock. +final class IntakeInbox { + private let root: URL, writer: String, now: () -> Int64 + private let fm = FileManager.default + private static let localLock = NSRecursiveLock() + init(root: URL, writer: String, now: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }) throws { + guard ["host", "extension"].contains(writer) else { throw IntakeFailure.unavailable } + self.root = IntakePolicy.canonical(root); self.writer = writer; self.now = now + try fm.createDirectory(at: self.root, withIntermediateDirectories: true) + var values = URLResourceValues(); values.isExcludedFromBackup = true + var ownedRoot = self.root; try ownedRoot.setResourceValues(values) + #if os(iOS) + try fm.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: self.root.path) + #endif + } + private func coordinated(onCoordinator: ((NSFileCoordinator) -> Void)? = nil, _ work: () throws -> T) throws -> T { + Self.localLock.lock(); defer { Self.localLock.unlock() } + var coordinationError: NSError?, outcome: Result? + let coordinator = NSFileCoordinator(); onCoordinator?(coordinator) + coordinator.coordinate(writingItemAt: root, options: .forMerging, error: &coordinationError) { _ in outcome = Result { try work() } } + if let coordinationError { throw coordinationError } + guard let outcome else { throw IntakeFailure.unavailable }; return try outcome.get() + } + private func directory(_ id: String) throws -> URL { + guard IntakePolicy.validID(id) else { throw IntakeFailure.unreadable }; return root.appendingPathComponent(id, isDirectory: true) + } + private func entries() throws -> [IntakeEntry] { + var result: [IntakeEntry] = [] + for dir in try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) where IntakePolicy.validID(dir.lastPathComponent) { + do { + guard IntakePolicy.canonical(dir).path == dir.standardizedFileURL.path else { throw IntakeFailure.invalidData } + let meta = dir.appendingPathComponent("metadata.json") + let info = try meta.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + guard info.isRegularFile == true, info.isSymbolicLink != true, (info.fileSize ?? 4097) <= 4096 else { throw IntakeFailure.invalidData } + let data = try Data(contentsOf: meta) + let entry = try JSONDecoder().decode(IntakeEntry.self, from: data) + let allowed = Set(["id", "createdAtMs", "kind", "name", "byteLength"] + (entry.kind == "file" ? ["format"] : entry.kind == "error" ? ["errorCode"] : [])) + guard let raw = try JSONSerialization.jsonObject(with: data) as? [String: Any], Set(raw.keys) == allowed, + entry.id == dir.lastPathComponent, entry.createdAtMs >= 0, now() - entry.createdAtMs < IntakePolicy.ttlMs, + entry.name == IntakePolicy.name(entry.name) else { throw IntakeFailure.expired } + if entry.kind == "error" { + guard entry.byteLength == 0, IntakeFailure(rawValue: entry.errorCode ?? "") != nil else { throw IntakeFailure.invalidData } + } else { + guard ["text", "file"].contains(entry.kind), entry.byteLength > 0, + entry.byteLength <= (entry.kind == "text" ? IntakePolicy.textBytes : IntakePolicy.fileBytes) else { throw IntakeFailure.invalidData } + let file = dir.appendingPathComponent("data") + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true, values.fileSize == entry.byteLength else { throw IntakeFailure.invalidData } + if entry.kind == "file", try IntakePolicy.format(entry.name) != entry.format { throw IntakeFailure.invalidData } + } + result.append(entry) + } catch { try? fm.removeItem(at: dir) } + } + for owner in ["host", "extension"] { + let folder = root.appendingPathComponent("staging/\(owner)") + for stage in (try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] { + if let epoch = Int64(stage.lastPathComponent.components(separatedBy: "-")[0]), now() - epoch > IntakePolicy.copyTimeoutMs + 60000 { try? fm.removeItem(at: stage) } + } + } + return result.sorted { $0.createdAtMs == $1.createdAtMs ? $0.id < $1.id : $0.createdAtMs < $1.createdAtMs } + } + func list() throws -> [IntakeEntry] { try coordinated { try entries() } } + func putText(_ text: String, cancelled: () -> Bool = { false }) throws -> IntakeEntry { + let bytes = Data(text.utf8) + guard text.utf16.count <= IntakePolicy.textLength, bytes.count <= IntakePolicy.textBytes else { throw IntakeFailure.tooLarge } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw IntakeFailure.unsupported } + return try copy(InputStream(data: bytes), kind: "text", name: "Shared location", format: nil, limit: IntakePolicy.textBytes, cancelled: cancelled) + } + /// A staging capability can only be created by this inbox; it owns cleanup. + final class StagedEntry { + fileprivate let owner: IntakeInbox, folder: URL, kind: String, name: String, format: String?, byteLength: Int + fileprivate init(owner: IntakeInbox, folder: URL, kind: String, name: String, format: String?, byteLength: Int) { + self.owner = owner; self.folder = folder; self.kind = kind; self.name = name; self.format = format; self.byteLength = byteLength + } + deinit { try? FileManager.default.removeItem(at: folder) } + } + func putFile(_ file: URL, name: String, deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 10, + cancelled: () -> Bool = { false }, onCoordinator: ((NSFileCoordinator) -> Void)? = nil) throws -> IntakeEntry { + let staged = try stageFile(file, name: name, deadline: deadline, cancelled: cancelled) + return try commit(staged, deadline: deadline, cancelled: cancelled, onCoordinator: onCoordinator) + } + /// No inbox coordination is held while reading a foreign file. O_NONBLOCK + /// prevents FIFO-open hangs; fstat checks the actual descriptor after open. + func stageFile(_ file: URL, name: String, deadline: TimeInterval, cancelled: () -> Bool, + validateOpened: ((URL) throws -> Void)? = nil) throws -> StagedEntry { + guard file.isFileURL, !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + let display = IntakePolicy.name(name), format = try IntakePolicy.format(display) + let fd = Darwin.open(file.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { throw IntakeFailure.unreadable }; defer { Darwin.close(fd) } + var info = stat() + guard fstat(fd, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, info.st_size >= 0 else { throw IntakeFailure.unreadable } + guard info.st_size <= IntakePolicy.fileBytes else { throw IntakeFailure.tooLarge } + if let validateOpened { + var path = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + guard fcntl(fd, F_GETPATH, &path) == 0 else { throw IntakeFailure.unreadable } + try validateOpened(URL(fileURLWithPath: String(cString: path))) + } + return try stage(kind: "file", name: display, format: format, limit: IntakePolicy.fileBytes, deadline: deadline, cancelled: cancelled) { buffer in + let count = Darwin.read(fd, &buffer, buffer.count) + guard count >= 0 else { throw IntakeFailure.unreadable }; return count + } + } + private func copy(_ input: InputStream, kind: String, name: String, format: String?, limit: Int, cancelled: () -> Bool = { false }) throws -> IntakeEntry { + let deadline = ProcessInfo.processInfo.systemUptime + 10 + input.open(); defer { input.close() } + let staged = try stage(kind: kind, name: name, format: format, limit: limit, deadline: deadline, cancelled: cancelled) { buffer in + let count = input.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { throw IntakeFailure.unreadable }; return count + } + return try commit(staged, deadline: deadline, cancelled: cancelled) + } + private func stage(kind: String, name: String, format: String?, limit: Int, deadline: TimeInterval, + cancelled: () -> Bool, read: (inout [UInt8]) throws -> Int) throws -> StagedEntry { + let folder = root.appendingPathComponent("staging/\(writer)/\(now())-\(UUID().uuidString)") + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + var retained = false; defer { if !retained { try? fm.removeItem(at: folder) } } + let file = folder.appendingPathComponent("data"); guard fm.createFile(atPath: file.path, contents: nil) else { throw IntakeFailure.unreadable } + let output = try FileHandle(forWritingTo: file); defer { try? output.close() } + var size = 0, buffer = [UInt8](repeating: 0, count: 8192) + while true { + guard !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + let capacity = min(8192, limit - size + 1) + if buffer.count != capacity { buffer = [UInt8](repeating: 0, count: capacity) } + let count = try read(&buffer) + if count == 0 { break } + size += count; guard size <= limit else { throw IntakeFailure.tooLarge } + try output.write(contentsOf: Data(buffer.prefix(count))) + } + guard size > 0 else { throw IntakeFailure.invalidData } + try output.synchronize() + let result = StagedEntry(owner: self, folder: folder, kind: kind, name: name, format: format, byteLength: size) + retained = true; return result + } + /// Called after the external accessor has returned, never inside its lock. + func commit(_ stage: StagedEntry, deadline: TimeInterval, cancelled: () -> Bool, + onCoordinator: ((NSFileCoordinator) -> Void)? = nil) throws -> IntakeEntry { + guard stage.owner === self, !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + return try coordinated(onCoordinator: onCoordinator) { + guard !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + let current = try entries().filter { $0.kind != "error" } + guard current.count < IntakePolicy.contentItems, current.reduce(0, { $0 + $1.byteLength }) + stage.byteLength <= IntakePolicy.inboxBytes else { throw IntakeFailure.inboxFull } + let item = IntakeEntry(id: UUID().uuidString.lowercased(), createdAtMs: now(), kind: stage.kind, name: stage.name, byteLength: stage.byteLength, format: stage.format) + try JSONEncoder().encode(item).write(to: stage.folder.appendingPathComponent("metadata.json"), options: .atomic) + guard !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + try fm.moveItem(at: stage.folder, to: directory(item.id)); return item + } + } + func recordError(_ code: String, cancelled: () -> Bool = { false }, onCoordinator: ((NSFileCoordinator) -> Void)? = nil) throws { try coordinated(onCoordinator: onCoordinator) { + guard !cancelled() else { throw IntakeFailure.unreadable } + let old = try entries().filter { $0.kind == "error" } + for item in old.prefix(max(0, old.count - IntakePolicy.errorItems + 1)) { try? fm.removeItem(at: directory(item.id)) } + let item = IntakeEntry(id: UUID().uuidString.lowercased(), createdAtMs: now(), kind: "error", name: "OpenMapX", byteLength: 0, errorCode: IntakeFailure(rawValue: code)?.rawValue ?? "unreadable") + guard !cancelled() else { throw IntakeFailure.unreadable } + let dir = try directory(item.id); try fm.createDirectory(at: dir, withIntermediateDirectories: true) + try JSONEncoder().encode(item).write(to: dir.appendingPathComponent("metadata.json"), options: .atomic) + } } + func read(_ id: String, offset: Int) throws -> IntakeChunk { try coordinated { + let dir = try directory(id) + guard offset >= 0, offset % IntakePolicy.chunkBytes == 0 else { throw IntakeFailure.unreadable } + guard let item = try entries().first(where: { $0.id == id && $0.kind != "error" }) else { throw IntakeFailure.expired } + guard offset < item.byteLength else { throw IntakeFailure.unreadable } + let handle = try FileHandle(forReadingFrom: dir.appendingPathComponent("data")); defer { try? handle.close() } + try handle.seek(toOffset: UInt64(offset)) + let count = min(IntakePolicy.chunkBytes, item.byteLength - offset) + let data = try handle.read(upToCount: count) ?? Data() + guard data.count == count else { throw IntakeFailure.unreadable } + return IntakeChunk(id: id, offset: offset, data: data.base64EncodedString(), byteLength: count, eof: offset + count == item.byteLength) + } } + func complete(_ id: String) throws { try coordinated { let dir = try directory(id); if fm.fileExists(atPath: dir.path) { try fm.removeItem(at: dir) } } } + func captureDocument(_ file: URL, osInbox: URL, openInPlace: Bool, + deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 10, cancelled: () -> Bool = { false }, + onCoordinator: ((NSFileCoordinator) -> Void)? = nil) throws -> IntakeEntry { + let canonical = IntakePolicy.canonical(file), inbox = IntakePolicy.canonical(osInbox) + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard !openInPlace, file.isFileURL, values.isRegularFile == true, values.isSymbolicLink != true, + canonical.deletingLastPathComponent().path == inbox.path else { throw IntakeFailure.unsupported } + // This is an OS-owned temporary copy, not the provider's original. Remove + // it on rejection too so oversized/full-inbox imports cannot accumulate in + // the app's backed-up Documents directory. + defer { try? fm.removeItem(at: canonical) } + return try putFile(canonical, name: file.lastPathComponent, deadline: deadline, cancelled: cancelled, onCoordinator: onCoordinator) + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakePolicy.swift b/apps/mobile/modules/openmapx-intake/ios/IntakePolicy.swift new file mode 100644 index 000000000..bcf2d71cc --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakePolicy.swift @@ -0,0 +1,21 @@ +import Foundation + +enum IntakeFailure: String, Error { case unsupported, tooLarge = "too-large", tooMany = "too-many", inboxFull = "inbox-full", unreadable, expired, unavailable, invalidData = "invalid-data" } +enum IntakePolicy { + static let fileBytes = 5 * 1024 * 1024, textBytes = 8192, textLength = 4096, chunkBytes = 24576 + static let inboxBytes = 20 * 1024 * 1024, contentItems = 8, errorItems = 2 + static let ttlMs: Int64 = 86400000, copyTimeoutMs: Int64 = 10000 + static func name(_ raw: String) -> String { + let leaf = raw.replacingOccurrences(of: "\\", with: "/").components(separatedBy: "/").last ?? "" + let clean = String(leaf.unicodeScalars.filter { $0.value >= 32 && !(0x202A...0x202E).contains($0.value) && !(0x2066...0x2069).contains($0.value) }) + var result = "" + for scalar in clean.unicodeScalars { if result.utf16.count + scalar.utf16.count > 128 { break }; result.unicodeScalars.append(scalar) } + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + static func format(_ name: String) throws -> String { + let ext = (name as NSString).pathExtension.lowercased() + guard ["gpx", "geojson", "kml"].contains(ext) else { throw IntakeFailure.unsupported }; return ext + } + static func validID(_ id: String) -> Bool { UUID(uuidString: id) != nil && id.count == 36 } + static func canonical(_ url: URL) -> URL { url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntake.podspec b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntake.podspec new file mode 100644 index 000000000..84763ea9a --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntake.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'OpenMapXIntake' + s.version = '1.0.0' + s.summary = 'Bounded local sharing and geographic document inbox' + s.description = 'Host receiver and immutable App Group inbox. Share UI is built only in the extension target.' + s.author = 'OpenMapX' + s.homepage = 'https://github.com/OpenMapX/openmapx' + s.license = 'AGPL-3.0-or-later' + s.platforms = { :ios => '16.4' } + s.source = { :git => '' } + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '*.swift' + s.frameworks = 'UIKit' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.resource_bundles = { 'OpenMapXIntake_privacy' => ['PrivacyInfo.xcprivacy'] } + s.swift_version = '5.9' +end diff --git a/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift new file mode 100644 index 000000000..f24152f7c --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift @@ -0,0 +1,27 @@ +import ExpoModulesCore +import Foundation +import UIKit + +public final class OpenMapXIntakeModule: Module { + private var observer: NSObjectProtocol? + private func result(_ work: () throws -> [String: Any]) -> [String: Any] { + var task = UIBackgroundTaskIdentifier.invalid + let start = { task = UIApplication.shared.beginBackgroundTask(withName: "OpenMapX inbox") } + if Thread.isMainThread { start() } else { DispatchQueue.main.sync(execute: start) } + defer { DispatchQueue.main.async { if task != .invalid { UIApplication.shared.endBackgroundTask(task) } } } + do { return try work() } catch { return ["errorCode": (error as? IntakeFailure)?.rawValue ?? "unavailable"] } + } + public func definition() -> ModuleDefinition { + Name("OpenMapXIntake") + Events("onInboxChanged") + OnCreate { + self.observer = NotificationCenter.default.addObserver(forName: IntakeContainer.changed, object: nil, queue: nil) { [weak self] _ in self?.sendEvent("onInboxChanged", [:]) } + } + OnDestroy { if let observer = self.observer { NotificationCenter.default.removeObserver(observer) }; self.observer = nil } + OnAppEntersForeground { self.sendEvent("onInboxChanged", [:]) } + AsyncFunction("isAvailableAsync") { (try? IntakeContainer.store(writer: "host")) != nil } + AsyncFunction("listAsync") { self.result { ["items": try IntakeContainer.store(writer: "host").list().map(\.dictionary)] } } + AsyncFunction("readChunkAsync") { (id: String, offset: Int) in self.result { try IntakeContainer.store(writer: "host").read(id, offset: offset).dictionary } } + AsyncFunction("completeAsync") { (id: String) in self.result { try IntakeContainer.store(writer: "host").complete(id); return ["ok": true] } } + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy b/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..143d4cf43 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,13 @@ + + + +NSPrivacyTracking +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp +NSPrivacyAccessedAPITypeReasonsC617.13B52.1 + +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategorySystemBootTime +NSPrivacyAccessedAPITypeReasons35F9.1 + + diff --git a/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy b/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..143d4cf43 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy @@ -0,0 +1,13 @@ + + + +NSPrivacyTracking +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp +NSPrivacyAccessedAPITypeReasonsC617.13B52.1 + +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategorySystemBootTime +NSPrivacyAccessedAPITypeReasons35F9.1 + + diff --git a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift new file mode 100644 index 000000000..fbb80a6cf --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -0,0 +1,169 @@ +import Foundation +import UIKit +import UniformTypeIdentifiers + +private final class TransferState { + private let lock = NSLock(); private var stopped = false + var cancelled: Bool { lock.lock(); defer { lock.unlock() }; return stopped } + func cancel() { lock.lock(); stopped = true; lock.unlock() } +} +/// Supported share-extension flow: save to the group, complete, then let the user open the app. +final class ShareViewController: UIViewController { + private let de = Locale.preferredLanguages.first?.hasPrefix("de") == true + private let label = UILabel(), add = UIButton(type: .system), done = UIButton(type: .system) + private var transfers: [TransferState] = [], progress: [Progress] = [] + private var adding = false, completed = false + private let heading = UILabel(), symbol = UIImageView() + private let operation = TransferState() + private var providers: [NSItemProvider] { (extensionContext?.inputItems as? [NSExtensionItem] ?? []).flatMap { $0.attachments ?? [] } } + private func copy(_ en: String, _ de: String) -> String { self.de ? de : en } + // Generated from packages/core/src/theme.ts; no separate native palette. + private func color(_ key: String) -> UIColor { + let theme = Bundle.main.object(forInfoDictionaryKey: "OpenMapXTheme") as? [String: [String: String]] ?? [:] + return UIColor { traits in + let hex = theme[traits.userInterfaceStyle == .dark ? "dark" : "light"]?[key] ?? "" + guard let value = UInt32(hex.replacingOccurrences(of: "#", with: ""), radix: 16) else { return .label } + return UIColor(red: CGFloat((value >> 16) & 255) / 255, green: CGFloat((value >> 8) & 255) / 255, blue: CGFloat(value & 255) / 255, alpha: 1) + } + } + private func text(_ value: String, style: UIFont.TextStyle, secondary: Bool = false) -> UILabel { + let label = UILabel(); label.text = value; label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: style); label.adjustsFontForContentSizeCategory = true + label.textColor = color(secondary ? "textSecondary" : "text"); return label + } + override func viewDidLoad() { + super.viewDidLoad(); view.backgroundColor = color("background"); view.tintColor = color("primary") + preferredContentSize = CGSize(width: 420, height: 540) + let scroll = UIScrollView(); scroll.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(scroll) + let stack = UIStackView(); stack.axis = .vertical; stack.spacing = 20; stack.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(stack) + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -24), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -24), + stack.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor, constant: -48) + ]) + let brand = text("OpenMapX", style: .headline); brand.textColor = color("primary") + done.setTitle(copy("Cancel", "Abbrechen"), for: .normal); done.setTitleColor(color("textSecondary"), for: .normal) + done.titleLabel?.font = .preferredFont(forTextStyle: .body); done.titleLabel?.adjustsFontForContentSizeCategory = true + done.addTarget(self, action: #selector(finish), for: .touchUpInside) + done.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + let top = UIStackView(arrangedSubviews: [brand, done]); top.axis = .horizontal; top.spacing = 16 + brand.setContentHuggingPriority(.defaultLow, for: .horizontal); done.setContentHuggingPriority(.required, for: .horizontal) + stack.addArrangedSubview(top) + symbol.image = UIImage(systemName: "square.and.arrow.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .medium)) + symbol.tintColor = color("primary"); symbol.contentMode = .left; symbol.heightAnchor.constraint(equalToConstant: 40).isActive = true + symbol.isAccessibilityElement = false; stack.addArrangedSubview(symbol) + heading.text = copy("Add to OpenMapX", "Zu OpenMapX hinzufügen"); heading.font = .preferredFont(forTextStyle: .title1) + heading.adjustsFontForContentSizeCategory = true; heading.numberOfLines = 0; heading.textColor = color("text") + heading.accessibilityTraits = .header; stack.addArrangedSubview(heading) + label.numberOfLines = 0; label.font = .preferredFont(forTextStyle: .body); label.adjustsFontForContentSizeCategory = true + label.textColor = color("textSecondary") + label.text = copy("Save now. Open OpenMapX afterward to review and import.", "Jetzt hinzufügen. Danach in OpenMapX prüfen und importieren.") + stack.addArrangedSubview(label) + let rows = providers.map { provider in ShareSelection.supportedTypes.filter { provider.hasItemConformingToTypeIdentifier($0) } } + let selection = try? ShareSelection.select(rows) + let summary = UIStackView(); summary.axis = .vertical; summary.spacing = 6 + switch selection { + case .files(let files): + for file in files { + let name = IntakePolicy.name(providers[file.index].suggestedName ?? copy("Geographic file", "Geodatei")) + summary.addArrangedSubview(text(name, style: .headline)) + } + summary.addArrangedSubview(text(files.map { $0.type == "com.topografix.gpx" ? "GPX" : $0.type == "public.geojson" ? "GeoJSON" : "KML" }.joined(separator: " · "), style: .subheadline, secondary: true)) + default: + summary.addArrangedSubview(text(copy("Shared location", "Geteilter Ort"), style: .headline)) + summary.addArrangedSubview(text(copy("Address or map link", "Adresse oder Kartenlink"), style: .subheadline, secondary: true)) + } + let card = UIView(); card.backgroundColor = color("surface") + let radius = Bundle.main.object(forInfoDictionaryKey: "OpenMapXCornerRadius") as? CGFloat ?? 8 + card.layer.cornerRadius = radius; summary.translatesAutoresizingMaskIntoConstraints = false; card.addSubview(summary) + NSLayoutConstraint.activate([summary.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), summary.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), summary.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), summary.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16)]) + stack.addArrangedSubview(card) + var configuration = UIButton.Configuration.filled(); configuration.title = copy("Add to OpenMapX", "Zu OpenMapX hinzufügen") + configuration.baseBackgroundColor = color("primary"); configuration.baseForegroundColor = color("onPrimary") + configuration.background.cornerRadius = radius; configuration.cornerStyle = .fixed + configuration.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) + add.configuration = configuration; add.titleLabel?.adjustsFontForContentSizeCategory = true + add.addTarget(self, action: #selector(save), for: .touchUpInside); stack.addArrangedSubview(add) + stack.addArrangedSubview(text(copy("Stays on your device for up to 24 hours. Nothing is added to saved places automatically.", "Bleibt bis zu 24 Stunden auf deinem Gerät. Gespeicherte Orte werden nicht automatisch ergänzt."), style: .footnote, secondary: true)) + add.isEnabled = selection != nil && !providers.isEmpty && providers.count <= 5 + if !add.isEnabled { label.text = copy("Share up to five GPX, GeoJSON or KML files, or one location.", "Bis zu fünf GPX-, GeoJSON- oder KML-Dateien oder einen Ort teilen.") } + } + private func showResult(_ success: Bool) { + completed = true; add.isEnabled = true; add.configuration?.showsActivityIndicator = false + add.configuration?.title = copy("Done", "Fertig") + done.setTitle(copy("Close", "Schließen"), for: .normal) + heading.text = success ? copy("Added to OpenMapX", "Zu OpenMapX hinzugefügt") : copy("Some items couldn't be added", "Einige Inhalte fehlen") + symbol.image = UIImage(systemName: success ? "checkmark.circle" : "exclamationmark.triangle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .medium)) + symbol.tintColor = color(success ? "primary" : "error") + label.text = success ? copy("Open OpenMapX to review your shared content.", "Öffne OpenMapX, um die geteilten Inhalte zu prüfen.") : copy("Open OpenMapX to review saved items and details.", "Öffne OpenMapX, um gespeicherte Inhalte und Hinweise zu prüfen.") + UIAccessibility.post(notification: .announcement, argument: heading.text) + } + @objc private func finish() { + operation.cancel() + transfers.forEach { $0.cancel() }; progress.forEach { $0.cancel() } + extensionContext?.completeRequest(returningItems: nil) + } + @objc private func save() { + if completed { finish(); return } + guard !adding else { return }; adding = true; add.isEnabled = false; add.configuration?.showsActivityIndicator = true + label.text = copy("Adding…", "Wird hinzugefügt…") + let items = providers + DispatchQueue.global(qos: .userInitiated).async { [self] in + do { + let store = try IntakeContainer.store(writer: "extension") + let rows = items.map { provider in ShareSelection.supportedTypes.filter { provider.hasItemConformingToTypeIdentifier($0) } } + let plan = try ShareSelection.select(rows) + let selections: [(NSItemProvider, String)] + switch plan { + case .files(let files): selections = files.map { (items[$0.index], $0.type) } + case .text(let indices): selections = indices.map { (items[$0], rows[$0].contains("public.url") ? "public.url" : "public.plain-text") } + } + var textValues: [String] = [] + for (provider, type) in selections { + if operation.cancelled { return } + let state = TransferState(), semaphore = DispatchSemaphore(value: 0) + var failure: Error? + DispatchQueue.main.sync { self.transfers.append(state) } + let completed: (Error?) -> Void = { error in failure = error; semaphore.signal() } + let transfer: Progress + if type == UTType.plainText.identifier || type == UTType.url.identifier { + transfer = Progress(totalUnitCount: 1) + provider.loadItem(forTypeIdentifier: type, options: nil) { item, error in + guard !(state.cancelled || self.operation.cancelled) else { completed(IntakeFailure.unreadable); return } + do { + if let error { throw error } + if let url = item as? URL, url.isFileURL { throw IntakeFailure.unsupported } + else if let url = item as? URL { textValues.append(url.absoluteString); _ = try ShareSelection.text(textValues, allowEmpty: true) } + else if let text = item as? String { textValues.append(text); _ = try ShareSelection.text(textValues, allowEmpty: true) } + else { throw IntakeFailure.unsupported } + completed(nil) + } catch { completed(error) } + } + } else { + transfer = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + do { + guard !(state.cancelled || self.operation.cancelled), let url, error == nil else { throw IntakeFailure.unreadable } + var name = provider.suggestedName ?? url.lastPathComponent + if (name as NSString).pathExtension.isEmpty { name += type == "com.topografix.gpx" ? ".gpx" : type == "public.geojson" ? ".geojson" : ".kml" } + _ = try store.putFile(url, name: name, cancelled: { state.cancelled || self.operation.cancelled }); completed(nil) + } catch { completed(error) } + } + } + DispatchQueue.main.sync { self.progress.append(transfer) } + if semaphore.wait(timeout: .now() + 10) == .timedOut { state.cancel(); transfer.cancel(); throw IntakeFailure.unreadable } + if let failure { throw failure } + } + if case .text = plan { _ = try store.putText(ShareSelection.text(textValues), cancelled: { self.operation.cancelled }) } + } catch { + if operation.cancelled { return } + try? IntakeContainer.store(writer: "extension").recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") + DispatchQueue.main.async { self.showResult(false) } + return + } + DispatchQueue.main.async { self.showResult(true) } + } + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift b/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift new file mode 100644 index 000000000..af56334dd --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift @@ -0,0 +1,32 @@ +import Foundation + +enum ShareSelection: Equatable { + struct File: Equatable { let index: Int; let type: String } + case text([Int]), files([File]) + static let fileTypes = ["com.topografix.gpx", "public.geojson", "com.google.earth.kml"] + static let textTypes = ["public.url", "public.plain-text"] + static let supportedTypes = fileTypes + textTypes + ["public.file-url"] + static func select(_ types: [[String]]) throws -> ShareSelection { + guard !types.isEmpty, types.count <= 5 else { throw IntakeFailure.tooMany } + let files = types.enumerated().compactMap { index, row in fileTypes.first(where: row.contains).map { File(index: index, type: $0) } } + if !files.isEmpty { + guard files.count == types.count else { throw IntakeFailure.unsupported } + return .files(files) + } + let text = types.indices.filter { !types[$0].contains("public.file-url") && types[$0].contains(where: textTypes.contains) } + guard !text.isEmpty else { throw IntakeFailure.unsupported } + return .text(text) + } + static func text(_ values: [String], allowEmpty: Bool = false) throws -> String { + guard values.allSatisfy({ $0.utf16.count <= IntakePolicy.textLength }) else { throw IntakeFailure.tooLarge } + var labels: [String] = [], link: String? + for line in values.flatMap({ $0.components(separatedBy: .newlines) }).map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }).filter({ !$0.isEmpty }) { + if line.range(of: "^(https?://|geo:|[a-z][a-z0-9+.-]*://)", options: [.regularExpression, .caseInsensitive]) != nil { if link == nil { link = line } } + else if !labels.contains(line) { labels.append(line) } + } + let combined = (labels + (link.map { [$0] } ?? [])).joined(separator: "\n") + guard allowEmpty || !combined.isEmpty else { throw IntakeFailure.unsupported } + guard combined.utf16.count <= IntakePolicy.textLength, combined.utf8.count <= IntakePolicy.textBytes else { throw IntakeFailure.tooLarge } + return combined + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeExternalDocumentTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeExternalDocumentTests.swift new file mode 100644 index 000000000..1ac9339f8 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeExternalDocumentTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Darwin +import XCTest +@testable import IntakeInbox + +private final class TestScope: IntakeDocumentScope { + var allowed = true + private let lock = NSLock() + private var starts = 0, stops = 0 + var acquired: (() -> Void)? + func start(_ url: URL) -> Bool { lock.lock(); starts += 1; lock.unlock(); acquired?(); return allowed } + func stop(_ url: URL) { lock.lock(); stops += 1; lock.unlock() } + var counts: [Int] { lock.lock(); defer { lock.unlock() }; return [starts, stops] } +} +private final class TestCoordinator: IntakeDocumentCoordinating { + var hang = false, fail = false, replacement: URL? + var entered: (() -> Void)? + var before: (() throws -> Void)? + var exited = false + private(set) var cancels = 0 + func cancel() { cancels += 1 } + func coordinate(_ url: URL, on queue: OperationQueue, accessor: @escaping (Result) -> Void) { + entered?() + if hang { return } + queue.addOperation { + do { try self.before?(); accessor(self.fail ? .failure(IntakeFailure.unreadable) : .success(self.replacement ?? url)) } + catch { accessor(.failure(error)) } + self.exited = true + } + } +} +final class IntakeExternalDocumentTests: XCTestCase { + var root: URL!, source: URL!, privateRoot: URL!, store: IntakeInbox! + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + source = root.appendingPathComponent("route.gpx") + try Data("owned fixture".utf8).write(to: source) + privateRoot = root.appendingPathComponent("app") + store = try IntakeInbox(root: privateRoot.appendingPathComponent("Inbox"), writer: "host") + } + override func tearDownWithError() throws { try? FileManager.default.removeItem(at: root) } + @discardableResult private func run(_ scope: TestScope, _ coordinator: TestCoordinator, timeout: TimeInterval = 1) -> Result { + let done = expectation(description: "capture finishes") + var outcome: Result! + let job = IntakeExternalDocument(url: source, store: store, privateRoots: [privateRoot], scope: scope, coordinator: coordinator, timeout: timeout) + job.start { outcome = $0; done.fulfill() } + wait(for: [done], timeout: 3) + return outcome + } + func testPrivacyReasonsCoverUserSelectedMetadataAndDeadlineTimers() throws { + let ios = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + for name in ["PrivacyInfo.xcprivacy", "Share/PrivacyInfo.xcprivacy"] { + let raw = try PropertyListSerialization.propertyList(from: Data(contentsOf: ios.appendingPathComponent(name)), format: nil) as? [String: Any] + let categories = try XCTUnwrap(raw?["NSPrivacyAccessedAPITypes"] as? [[String: Any]]) + let reasons = Dictionary(uniqueKeysWithValues: categories.map { + ($0["NSPrivacyAccessedAPIType"] as? String ?? "", Set($0["NSPrivacyAccessedAPITypeReasons"] as? [String] ?? [])) + }) + XCTAssertTrue(reasons["NSPrivacyAccessedAPICategoryFileTimestamp", default: []].isSuperset(of: ["C617.1", "3B52.1"])) + XCTAssertEqual(reasons["NSPrivacyAccessedAPICategorySystemBootTime"], ["35F9.1"]) + } + } + func testBackgroundExpirationAndCompletionEndTheSameTaskOnlyOnce() { + var expire: (() -> Void)?, ended: [Int] = [], cancellations = 0 + let lease = IntakeBackgroundLease(invalid: 0, begin: { expire = $0; return 7 }, end: { ended.append($0) }) + lease.begin { cancellations += 1 }; expire?(); lease.end(); expire?() + XCTAssertEqual(cancellations, 1); XCTAssertEqual(ended, [7]) + } + func testBackgroundExpirationBeforeBeginReturnsAndInvalidAssertionsAreSafe() { + var ended: [Int] = [], cancellations = 0 + let early = IntakeBackgroundLease(invalid: 0, begin: { expire in expire(); return 7 }, end: { ended.append($0) }) + early.begin { cancellations += 1 }; early.end() + let invalid = IntakeBackgroundLease(invalid: 0, begin: { _ in 0 }, end: { ended.append($0) }) + invalid.begin {}; invalid.end(); invalid.end() + XCTAssertEqual(cancellations, 1); XCTAssertEqual(ended, [7]) + } + func testCopyCommitsOnlyAfterAccessorExitsAndNeverChangesOriginal() throws { + let scope = TestScope(), coordinator = TestCoordinator(), original = try Data(contentsOf: source) + let item = try run(scope, coordinator).get() + XCTAssertTrue(coordinator.exited) + XCTAssertEqual(scope.counts, [1, 1]) + XCTAssertEqual(try Data(contentsOf: source), original) + XCTAssertEqual(try store.read(item.id, offset: 0).data, original.base64EncodedString()) + } + func testDeniedScopeNeverReadsOrReleasesAnUnacquiredScope() throws { + let scope = TestScope(); scope.allowed = false + XCTAssertThrowsError(try run(scope, TestCoordinator()).get()) + XCTAssertEqual(scope.counts, [1, 0]); XCTAssertTrue(try store.list().isEmpty) + } + func testScopeReleasedOnCoordinationAndSizeErrors() throws { + let bad = TestCoordinator(); bad.fail = true + let scope = TestScope(); XCTAssertThrowsError(try run(scope, bad).get()); XCTAssertEqual(scope.counts, [1, 1]) + try Data(repeating: 65, count: IntakePolicy.fileBytes + 1).write(to: source) + let largeScope = TestScope() + XCTAssertThrowsError(try run(largeScope, TestCoordinator()).get()) { XCTAssertEqual($0 as? IntakeFailure, .tooLarge) } + XCTAssertEqual(largeScope.counts, [1, 1]); XCTAssertTrue(try store.list().isEmpty) + } + func testTimeoutCancelsAnUnresponsiveCoordinatorAndReleasesScope() throws { + let scope = TestScope(), coordinator = TestCoordinator(); coordinator.hang = true + XCTAssertThrowsError(try run(scope, coordinator, timeout: 0.05).get()) + XCTAssertEqual(scope.counts, [1, 1]); XCTAssertEqual(coordinator.cancels, 1) + XCTAssertTrue(try store.list().isEmpty) + } + func testCancellationReleasesExactlyOnceAndFinishesExactlyOnce() throws { + let scope = TestScope(), coordinator = TestCoordinator(); coordinator.hang = true + let acquired = expectation(description: "scope acquired"), done = expectation(description: "cancelled") + coordinator.entered = { acquired.fulfill() } + let job = IntakeExternalDocument(url: source, store: store, privateRoots: [privateRoot], scope: scope, coordinator: coordinator) + job.start { result in + if case .success = result { XCTFail("cancelled job committed") }; done.fulfill() + } + wait(for: [acquired], timeout: 1); job.cancel(); job.cancel() + wait(for: [done], timeout: 1) + XCTAssertEqual(scope.counts, [1, 1]); XCTAssertEqual(coordinator.cancels, 1) + } + func testRejectsPrivateContainersAndCoordinatedURLReplacement() throws { + let privateFile = privateRoot.appendingPathComponent("secret.gpx"); try Data("private".utf8).write(to: privateFile) + source = privateFile; let scope = TestScope() + XCTAssertThrowsError(try run(scope, TestCoordinator()).get()); XCTAssertEqual(scope.counts, [1, 1]) + source = root.appendingPathComponent("route.gpx") + let coordinator = TestCoordinator(); coordinator.replacement = privateFile + let replacementScope = TestScope(); XCTAssertThrowsError(try run(replacementScope, coordinator).get()) + XCTAssertEqual(replacementScope.counts, [1, 1]); XCTAssertTrue(try store.list().isEmpty) + } + func testRejectsSymlinksAndFIFOSwappedAfterCoordinationStartsWithoutBlocking() throws { + for kind in ["symlink", "fifo"] { + try? FileManager.default.removeItem(at: source) + try Data("regular before coordination".utf8).write(to: source) + let coordinator = TestCoordinator() + coordinator.before = { + try FileManager.default.removeItem(at: self.source) + if kind == "symlink" { + let target = self.root.appendingPathComponent("other.gpx"); try Data("other".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: self.source, withDestinationURL: target) + } else { XCTAssertEqual(mkfifo(self.source.path, 0o600), 0) } + } + let scope = TestScope(); XCTAssertThrowsError(try run(scope, coordinator).get()); XCTAssertEqual(scope.counts, [1, 1]) + XCTAssertTrue(coordinator.exited) + XCTAssertTrue(try store.list().isEmpty) + } + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift new file mode 100644 index 000000000..ae958d1e1 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift @@ -0,0 +1,98 @@ +import Foundation +import XCTest +@testable import IntakeInbox +final class IntakeInboxTests: XCTestCase { + var root: URL! + override func setUpWithError() throws { root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } + override func tearDownWithError() throws { try? FileManager.default.removeItem(at: root) } + func testRestartChunksAndExplicitCompletion() throws { + let store = try IntakeInbox(root: root, writer: "host") + let item = try store.putText("Berlin") + let restored = try IntakeInbox(root: root, writer: "extension") + XCTAssertEqual(try restored.list().first?.id, item.id) + XCTAssertEqual(try restored.read(item.id, offset: 0).data, Data("Berlin".utf8).base64EncodedString()) + try restored.complete(item.id); try restored.complete(item.id) + XCTAssertTrue(try restored.list().isEmpty) + XCTAssertThrowsError(try restored.read("../private", offset: 0)) + } + func testCapsTTLAndFullInboxErrors() throws { + var time: Int64 = 1000 + let store = try IntakeInbox(root: root, writer: "host", now: { time }) + XCTAssertThrowsError(try store.putText(String(repeating: "a", count: 4097))) + for _ in 0..<8 { _ = try store.putText("Berlin") } + XCTAssertThrowsError(try store.putText("Paris")) + for _ in 0..<3 { try store.recordError("inbox-full") } + XCTAssertEqual(try store.list().count, 10) + time += IntakePolicy.ttlMs + XCTAssertTrue(try store.list().isEmpty) + } + func testDocumentAuthorityAndDeleteAfterCopy() throws { + let inbox = root.appendingPathComponent("Documents/Inbox") + try FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true) + let file = inbox.appendingPathComponent("route.gpx") + try Data("".utf8).write(to: file) + let store = try IntakeInbox(root: root.appendingPathComponent("owned"), writer: "host") + XCTAssertThrowsError(try store.captureDocument(file, osInbox: inbox, openInPlace: true)) + let outside = root.appendingPathComponent("private.gpx") + try Data("private".utf8).write(to: outside) + XCTAssertThrowsError(try store.captureDocument(outside, osInbox: inbox, openInPlace: false)) + let symlink = inbox.appendingPathComponent("link.gpx") + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: outside) + XCTAssertThrowsError(try store.captureDocument(symlink, osInbox: inbox, openInPlace: false)) + XCTAssertEqual(try store.captureDocument(file, osInbox: inbox, openInPlace: false).byteLength, 6) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + } + func testLiveOtherWriterStagingIsKept() throws { + let store = try IntakeInbox(root: root, writer: "host", now: { 100000 }) + let stage = root.appendingPathComponent("staging/extension") + try FileManager.default.createDirectory(at: stage, withIntermediateDirectories: true) + let live = stage.appendingPathComponent("99000-\(UUID().uuidString)") + let stale = stage.appendingPathComponent("1000-\(UUID().uuidString)") + try Data().write(to: live); try Data().write(to: stale) + _ = try store.list() + XCTAssertTrue(FileManager.default.fileExists(atPath: live.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path)) + } +} + +extension IntakeInboxTests { + func testActualFileSizeAndMalformedMetadataAreRejected() throws { + let store = try IntakeInbox(root: root.appendingPathComponent("owned"), writer: "host") + let file = root.appendingPathComponent("large.gpx") + try Data(repeating: 65, count: IntakePolicy.fileBytes + 1).write(to: file) + XCTAssertThrowsError(try store.putFile(file, name: "large.gpx")) + XCTAssertTrue(try store.list().isEmpty) + let item = try store.putText("Berlin") + let meta = root.appendingPathComponent("owned/\(item.id)/metadata.json") + var raw = item.dictionary; raw["path"] = "/private/file" + try JSONSerialization.data(withJSONObject: raw).write(to: meta) + XCTAssertTrue(try store.list().isEmpty) + } + func testConcurrentWritersDoNotExceedQuota() throws { + let group = DispatchGroup(), lock = NSLock() + var successes = 0 + for i in 0..<16 { + group.enter() + DispatchQueue.global().async { + defer { group.leave() } + if let store = try? IntakeInbox(root: self.root, writer: i % 2 == 0 ? "host" : "extension"), (try? store.putText("Berlin")) != nil { + lock.lock(); successes += 1; lock.unlock() + } + } + } + XCTAssertEqual(group.wait(timeout: .now() + 10), .success) + XCTAssertEqual(successes, 8) + XCTAssertEqual(try IntakeInbox(root: root, writer: "host").list().count, 8) + } + func testChunksRequireAlignedOffsetsAndCancellationNeverCommits() throws { + let store = try IntakeInbox(root: root.appendingPathComponent("owned"), writer: "host") + let file = root.appendingPathComponent("route.gpx") + try Data(repeating: 65, count: 50000).write(to: file) + XCTAssertThrowsError(try store.putFile(file, name: "route.gpx", cancelled: { true })) + XCTAssertTrue(try store.list().isEmpty) + let item = try store.putFile(file, name: "route.gpx") + XCTAssertEqual(try store.read(item.id, offset: 49152).byteLength, 848) + XCTAssertTrue(try store.read(item.id, offset: 49152).eof) + XCTAssertThrowsError(try store.read(item.id, offset: 1)) + } +} diff --git a/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift new file mode 100644 index 000000000..e29260882 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import IntakeInbox +final class ShareSelectionTests: XCTestCase { + func testTextAndURLWithAnIrrelevantPreviewCombineIntoOneTextEntry() throws { + let plan = try ShareSelection.select([["public.url"], ["public.plain-text"], ["public.vcard"]]) + XCTAssertEqual(plan, .text([0, 1])) + XCTAssertEqual(try ShareSelection.text(["Station", "https://maps.example/short", "Station"]), "Station\nhttps://maps.example/short") + } + func testGeographicFileModeNeverSilentlyIgnoresOtherFiles() throws { + XCTAssertEqual(try ShareSelection.select([["com.topografix.gpx"], ["public.geojson"]]), .files([.init(index: 0, type: "com.topografix.gpx"), .init(index: 1, type: "public.geojson")])) + XCTAssertThrowsError(try ShareSelection.select([["com.topografix.gpx"], ["public.pdf"]])) + XCTAssertThrowsError(try ShareSelection.select([["public.file-url", "public.plain-text"]])) + } + func testAnEmptyFirstProviderDoesNotRejectTheFollowingLocation() throws { + XCTAssertEqual(try ShareSelection.text([" "], allowEmpty: true), "") + XCTAssertEqual(try ShareSelection.text([" ", "Station", "https://maps.apple.com/?q=Station"]), "Station\nhttps://maps.apple.com/?q=Station") + XCTAssertThrowsError(try ShareSelection.text([" "])) + } + func testCombinedTextIsBoundedBeforeItCanBeCommitted() throws { + XCTAssertThrowsError(try ShareSelection.text([String(repeating: "x", count: 3000), String(repeating: "y", count: 3000)])) + XCTAssertThrowsError(try ShareSelection.select(Array(repeating: ["public.url"], count: 6))) + } +} + +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +extension ShareSelectionTests { + func testGeoJSONIdentifierIsDeclaredByApple() throws { + if #available(macOS 15.0, *) { + XCTAssertEqual(UTType.geoJSON.identifier, "public.geojson") + XCTAssertEqual(UTType(filenameExtension: "geojson")?.identifier, "public.geojson") + } + } +} +#endif diff --git a/apps/mobile/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index 67564775d..be4d9250f 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -71,7 +71,7 @@ export const FORBIDDEN_IOS_ENTITLEMENTS = ["aps-environment"] as const; */ export const FORBIDDEN_IOS_SHARING_KEYS = [ "UIFileSharingEnabled", - "LSSupportsOpeningDocumentsInPlace", + "UISupportsDocumentBrowser", ] as const; /** @@ -87,6 +87,7 @@ export function applyDataProtection(plist: InfoPlistLike): InfoPlistLike { const next: InfoPlistLike = { ...plist, NSFileProtectionKey: IOS_DATA_PROTECTION_CLASS, + LSSupportsOpeningDocumentsInPlace: true, }; for (const key of FORBIDDEN_IOS_SHARING_KEYS) delete next[key]; return next; diff --git a/apps/mobile/plugins/withNativeIntake.test.ts b/apps/mobile/plugins/withNativeIntake.test.ts new file mode 100644 index 000000000..da15464a1 --- /dev/null +++ b/apps/mobile/plugins/withNativeIntake.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { THEME_COLORS, THEME_CORNER_RADIUS } from "../../../packages/core/src/theme"; +import { intakeReceiverDeclaration } from "../config/nativeIntakeConfig"; +import { + intakeAndroidFilters, + intakeDocumentTypes, + intakeImportedTypes, + intakeShareInfo, + shareBuildSettings, +} from "./withNativeIntake"; + +describe("native intake CNG", () => { + it("restricts geographic document types without exposing the container", () => { + expect(intakeDocumentTypes()[0].LSItemContentTypes).toEqual([ + "com.topografix.gpx", + "public.geojson", + "com.google.earth.kml", + ]); + expect(intakeImportedTypes().map((t) => t.UTTypeIdentifier)).toContain("public.geojson"); + const info = intakeShareInfo("net.example.maps"); + expect(info.OpenMapXIntakeAppGroup).toBe("group.net.example.maps.intake"); + expect(info.OpenMapXTheme).toEqual(THEME_COLORS); + expect(info.OpenMapXCornerRadius).toBe(THEME_CORNER_RADIUS); + const rule = info.NSExtension.NSExtensionAttributes.NSExtensionActivationRule; + expect(rule).toContain("SUBQUERY"); + expect(rule).toContain("public.file-url"); + expect(rule).not.toContain("TRUEPREDICATE"); + }); + it("derives extension identity and versions and limits entitlements", () => { + const settings = shareBuildSettings( + { CURRENT_PROJECT_VERSION: "42", MARKETING_VERSION: "2.0", DEVELOPMENT_TEAM: "ABCDEFGHIJ" }, + "net.example.maps", + ); + expect(settings.PRODUCT_BUNDLE_IDENTIFIER).toBe('"net.example.maps.ShareExtension"'); + expect(settings.CODE_SIGN_ENTITLEMENTS).toBe( + '"OpenMapXShareExtension/ShareExtension.entitlements"', + ); + expect(settings.CURRENT_PROJECT_VERSION).toBe("42"); + expect(settings.MARKETING_VERSION).toBe("2.0"); + expect(settings.DEVELOPMENT_TEAM).toBe("ABCDEFGHIJ"); + expect(settings.APPLICATION_EXTENSION_API_ONLY).toBe("YES"); + expect(settings).not.toHaveProperty("SWIFT_OPTIMIZATION_LEVEL"); + }); + it("registers only typed intents, content VIEW is not browsable", () => { + const filters = intakeAndroidFilters(); + expect( + filters + .filter((f) => f.action === "VIEW") + .every( + (f) => f.category.join() === "DEFAULT" && f.data.every((d) => d.scheme === "content"), + ), + ).toBe(true); + expect(JSON.stringify(filters)).not.toContain('"*/*"'); + expect(filters.map((f) => f.action)).toContain("SEND_MULTIPLE"); + }); +}); + +it("routes external content through a nonhistorical receiver instead of Expo lifecycle hooks", () => { + const receiver = intakeReceiverDeclaration(); + expect(receiver.$["android:noHistory"]).toBe("true"); + expect(receiver.$["android:taskAffinity"]).toBe(""); + expect(receiver.$["android:excludeFromRecents"]).toBe("true"); + expect(JSON.stringify(receiver["intent-filter"])).toContain("android.intent.action.SEND"); +}); diff --git a/apps/mobile/plugins/withNativeIntake.ts b/apps/mobile/plugins/withNativeIntake.ts new file mode 100644 index 000000000..e5edfac5f --- /dev/null +++ b/apps/mobile/plugins/withNativeIntake.ts @@ -0,0 +1,148 @@ +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import plistModule from "@expo/plist"; +import { + AndroidConfig, + type ConfigPlugin, + withAndroidManifest, + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} from "expo/config-plugins"; +import { + INTAKE_RECEIVER, + intakeDocumentTypes, + intakeImportedTypes, + intakeReceiverDeclaration, + intakeShareInfo, + shareBuildSettings, + TARGET, +} from "../config/nativeIntakeConfig.ts"; + +export { + intakeAndroidFilters, + intakeDocumentTypes, + intakeImportedTypes, + intakeReceiverDeclaration, + intakeShareInfo, + shareBuildSettings, +} from "../config/nativeIntakeConfig.ts"; + +const plist = ((plistModule as { default?: unknown }).default ?? plistModule) as { + build(value: unknown): string; +}; +const withNativeIntake: ConfigPlugin = (config) => { + const appId = config.ios?.bundleIdentifier; + if (!appId) throw new Error("native intake requires a bundle identifier"); + const group = `group.${appId}.intake`; + let next = withInfoPlist(config, (mod) => { + Object.assign(mod.modResults, { + CFBundleDocumentTypes: intakeDocumentTypes(), + UTImportedTypeDeclarations: intakeImportedTypes(), + OpenMapXIntakeAppGroup: group, + }); + return mod; + }); + next = withEntitlementsPlist(next, (mod) => { + mod.modResults["com.apple.security.application-groups"] = [group]; + return mod; + }); + next = withXcodeProject(next, (mod) => { + const project = mod.modResults, + folder = join(mod.modRequest.platformProjectRoot, TARGET); + mkdirSync(folder, { recursive: true }); + const committed = join(mod.modRequest.projectRoot, "modules/openmapx-intake/ios"); + const sources = [ + "IntakePolicy.swift", + "IntakeInbox.swift", + "IntakeContainer.swift", + "ShareSelection.swift", + "ShareViewController.swift", + ]; + for (const source of sources) + copyFileSync( + join(committed, source === "ShareViewController.swift" ? "Share" : "", source), + join(folder, source), + ); + copyFileSync( + join(committed, "Share/PrivacyInfo.xcprivacy"), + join(folder, "PrivacyInfo.xcprivacy"), + ); + writeFileSync(join(folder, "Info.plist"), plist.build(intakeShareInfo(appId))); + writeFileSync( + join(folder, "ShareExtension.entitlements"), + plist.build({ "com.apple.security.application-groups": [group] }), + ); + const targets = project.pbxNativeTargetSection() as Record< + string, + { name?: string; buildConfigurationList: string } + >; + const existing = Object.entries(targets).find( + ([, t]) => t && typeof t === "object" && t.name?.replaceAll('"', "") === TARGET, + ); + const target = existing + ? { uuid: existing[0], pbxNativeTarget: existing[1] } + : project.addTarget(TARGET, "app_extension", TARGET, `${appId}.ShareExtension`); + if (!existing) { + project.addBuildPhase( + sources.map((s) => `${TARGET}/${s}`), + "PBXSourcesBuildPhase", + "Sources", + target.uuid, + ); + project.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", target.uuid); + project.addBuildPhase( + [`${TARGET}/PrivacyInfo.xcprivacy`], + "PBXResourcesBuildPhase", + "Resources", + target.uuid, + ); + } + const configs = project.pbxXCBuildConfigurationSection() as Record< + string, + { name: string; buildSettings: Record } + >; + const list = project.pbxXCConfigurationList()[target.pbxNativeTarget.buildConfigurationList]; + for (const entry of list.buildConfigurations) { + const extension = configs[entry.value]; + const host = Object.values(configs).find( + (c) => + c && + typeof c === "object" && + c.name === extension.name && + String(c.buildSettings?.PRODUCT_BUNDLE_IDENTIFIER).replaceAll('"', "") === appId, + ); + extension.buildSettings = shareBuildSettings( + { + ...host?.buildSettings, + MARKETING_VERSION: config.version ?? host?.buildSettings?.MARKETING_VERSION, + CURRENT_PROJECT_VERSION: + config.ios?.buildNumber ?? host?.buildSettings?.CURRENT_PROJECT_VERSION ?? "1", + }, + appId, + ); + } + return mod; + }); + next = withAndroidManifest(next, (mod) => { + const app = AndroidConfig.Manifest.getMainApplicationOrThrow(mod.modResults); + for (const activity of app.activity ?? []) { + if (activity.$["android:name"] === INTAKE_RECEIVER) continue; + activity["intent-filter"] = activity["intent-filter"]?.filter( + (filter) => + !filter.action?.some((a) => + ["android.intent.action.SEND", "android.intent.action.SEND_MULTIPLE"].includes( + a.$["android:name"], + ), + ) && !filter.data?.some((d) => d.$["android:scheme"] === "content"), + ); + } + app.activity = [ + ...(app.activity ?? []).filter((a) => a.$["android:name"] !== INTAKE_RECEIVER), + intakeReceiverDeclaration(), + ]; + return mod; + }); + return next; +}; +export default withNativeIntake; diff --git a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts index df413832a..b433a90d9 100644 --- a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts +++ b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts @@ -356,6 +356,13 @@ describe("iOS storage protection", () => { ); }); + it("allows Files to offer OpenMapX without exposing its private container", () => { + expect(applyDataProtection({}).LSSupportsOpeningDocumentsInPlace).toBe(true); + expect(applyDataProtection({ UISupportsDocumentBrowser: true })).not.toHaveProperty( + "UISupportsDocumentBrowser", + ); + }); + it.each(FORBIDDEN_IOS_SHARING_KEYS)( "removes %s, which would expose the session database", (key) => { diff --git a/apps/mobile/qa/release/store-review-checklist.md b/apps/mobile/qa/release/store-review-checklist.md index 3557e901d..dc3067254 100644 --- a/apps/mobile/qa/release/store-review-checklist.md +++ b/apps/mobile/qa/release/store-review-checklist.md @@ -52,4 +52,4 @@ they match observed installed behavior. - [ ] The uploaded artifact's SHA-256 matches the one in the release manifest. - [ ] The reviewed web build is frozen for the duration of review; a deploy mid-review changes what the reviewer sees. -- [ ] The reviewed shell and frozen web build both advertise protocol v3 only. +- [ ] The reviewed shell and frozen web build negotiate an overlapping protocol (3 for legacy navigation; 4 for native intake). diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md new file mode 100644 index 000000000..db16877b1 --- /dev/null +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -0,0 +1,66 @@ +# Native sharing and file intake qualification + +Dates: 2026-11-04–2026-11-05. Development identity `org.openmapx.intake.dev`, scheme `openmapx-intake-dev`. Source/build checks and runtime observations are separate; this is not store-release acceptance. + +## Automated evidence + +- Repository CI passed all five required checks on `24753090a`, before the final runtime corrections. Earlier local full verification passed 2,023 test files / 24,916 tests. Subsequent full attempts could not acquire the shared verification lock; they are not passes. +- Final runtime candidate: 83 mobile Jest suites / 1,409 tests passed, plus mobile types and all three web package TypeScript configurations. Ten focused protocol, bridge, parser and native-config suites passed 225 tests; after the privacy corrections, five affected suites passed 156 tests. These sets overlap. +- SwiftPM passed 22 tests: inbox persistence, limits, immutable chunks, combined share selection, source authority, bounded descriptor reads, symlink/FIFO replacement, scope denial/release, cancellation/timeout, background-task expiry and both privacy manifests. Injected scope/coordinator tests do not establish real OS grants. +- Android debug APK and 18 intake JVM tests passed. Earlier all four native JVM suites passed 67 tests. Robolectric does not implement real pipe polling; the emulator provider checks below supply that evidence. +- iOS Simulator Debug and Release host plus navigation/share extensions built successfully. The Release build embeds JavaScript and includes the final privacy resources; it was used for cold Files delivery. +- Two clean HTTPS native generations agreed. Generated-surface, reviewed permissions, dependency inventory, installed WebView capture/document-start policy and headless-bundle checks passed. No generated project is tracked. Formatting and affected package types passed. + +## Observed runtime behavior + +| Platform / sender | Observed result | Boundary | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Android API 36.1, separate QA app | Real OS sharing of address/map text and GPX, GeoJSON and KML from a foreign pipe-backed provider reached preview; explicit Open/import/replacement completed. Direct `content:` GPX VIEW also reached review and overlay replacement. | The controlled sender is a real native app/provider, not Drive or Gmail. | +| Android, rebuilt document-start patch | Repeated text → GeoJSON share → GPX document VIEW → KML share completed without the earlier channel invalidation. History changes retained a usable bridge. | No claim about every WebView version or the separate fast consecutive real-load nonce-prop race. | +| Android negative cases | A provider reporting a false small size but delivering over 5 MiB was rejected with the limit message. Mixed GPX/PDF produced the unsupported-content message. Neither offered Import. | Hung/cloud-provider cancellation still needs provider/device qualification. | +| Android load failure/retry | Loss of the owned port forwarding produced the native connection-error screen. Restoring forwarding and pressing Try again restored the map and delivered the pending oversized-file error. | Error-screen stability and successful retry observed; exact callback ordering for every Chromium error page remains unqualified. | +| iOS 27, separate QA app | Real Share Extension Add → saved confirmation → Done → manual host open worked for text and all three formats. GeoJSON was repeated after the shared descriptor-reader change and imported successfully. | No unsupported automatic host launch. | +| iOS 27 Files, warm host | A real 197-byte GPX saved On My iPhone appeared in Files Open With, launched the host, reached GPX review, and imported the map overlay. | Local Files provider, not iCloud/third-party cloud. | +| iOS 27 Files, stopped host | With the Simulator Release binary installed and host stopped, Files Quick Look → OpenMapX Dev launched the host, produced one GPX preview and imported it. The Files original remained present at 197 bytes. | Simulator local ad-hoc signing; no physical-device or production provisioning claim. | +| Browser, 390 × 844 | Busy-native transport fixture disabled Import with the deferred explanation. | Rendering proof only; actual active-trip deferral remains unqualified. | + +PR screenshots are actual native preview/import surfaces on both platforms, plus the iOS Share Extension. The deferred-state screenshot is explicitly a browser fixture. Final additional captures show iOS cold Files preview/import and Android KML preview/import. Images are unedited. QA senders and a transparent local development proxy lived outside the repository; no shipped test entry points, fixture transport or input-code injection was used for native receiving. + +## Runtime corrections and limits + +A callback trace showed Android history callbacks reporting `loading=true` after page finish without a new `onPageStarted`. Progress was therefore not a safe document identity signal. The pinned WebView patch now tags true document starts before bootstrap injection; history still reaches back/navigation state but cannot reset the channel. Missing tags fail closed. No late-descriptor bootstrap fallback, marker or origin relaxation was added. Temporary callback diagnostics were removed before rebuilding. + +An A/B Files experiment showed that `LSSupportsOpeningDocumentsInPlace=false` hid OpenMapX from Open With on iOS 27. The final registration is true. Only OS-delivered URLs with a positive security-scope grant enter the bounded host reader; the original is never mutated. Existing non-in-place Inbox copies retain separate strict handling. The app container is not exposed through file sharing or a document browser. + +During qualification, one Android command reported an adb timeout. Its prescribed server restart and one retry recovered automation; port forwarding was restored. An iOS debug cold start lacked its Metro bundle URL, so cold Files acceptance was repeated successfully with embedded JavaScript in a Release Simulator build. These development-tool incidents are not counted as intake passes. Earlier automation failures were recovered in this later run. + +Still unqualified: actual active-trip deferral; process-death/Recents replay including API 30 or lower; old-web inbox overflow on a device; real hung/cloud providers; Files, browser-download and Drive/Gmail MIME matrices; older-iOS registration; physical-device background behavior; production Apple team/App Group provisioning. Generic MIME filters have not been broadened without provider evidence. iOS policy-start handling for fragments/noncommitting navigations and fast real-load nonce-prop delivery remain separately documented lifecycle limits. + +## Release prerequisites + +Final Claude review approved the runtime corrections with no blocking correctness +or security findings. The expiration handler now ends its background assertion +synchronously when already on the main thread. Two low-severity limits remain: +the ten-second budget includes serial queue waiting, and cancellation racing the +final rename can produce a completed item alongside an unreadable notice. The +item remains reviewable; no source file is changed. + +Deploy compatible web first; protocol 3 retains navigation and protocol 4 enables intake. Rebuild native apps. Register/provision `.ShareExtension` and `group..intake` for host and Share Extension only; the navigation widget has no group. Simulator local ad-hoc signing does not establish Apple team, provisioning or App Store acceptance. + +Local HTTP was used only for development runtime QA. Afterward, two clean HTTPS generations and reviewed generated-permission checks passed. The installed local-HTTP Simulator build is not a release transport artifact. Production signing and final release binaries still require their release gates. + +## Share Extension theme correction + +The extension now receives both palettes and the corner radius from the shared core theme through generated Info.plist metadata. It uses a top-aligned, scrollable layout, content-type/name summary, filled primary action, Dynamic Type and distinct saving/success/error presentation. The system appearance controls light/dark colors; no host theme preference or extra App Group data is read. The Simulator Release build was inspected in light, dark and accessibility-large text size. Add produced the success state and Done dismissed the extension. Native config/generated-policy tests passed 48 tests, mobile types and generated checks passed, and the host plus both extensions built. This visual revision was self-reviewed; the earlier Claude approval covers the preceding intake/runtime corrections. + +## Final full-review corrections + +Current Apple Maps unified place/search/frame URLs and Google Maps place/@ URLs now classify locally, preserving labels and coordinate bounds. No redirect following or network access was added. Bare short links remain unsupported; sender labels can still be searched. Regression cases include malformed paths and out-of-range coordinates. + +Intake distinguishes unavailable native navigation authority from an active session. Normal startup/session-read failure returns a correlated `navigation-unavailable` error using the existing bridge error envelope, retains owned items and prevents application/acknowledgement. The web shows explicit reopen/recheck guidance instead of claiming an active trip. Feasibility builds deliberately skip the normal navigation composition and permit intake. Tests cover idle/active/startup failure/storage failure/feasibility and web recovery without applying or deleting items. No protocol-version or strict hello/listing schema changed. The new error notice is covered by component rendering/interaction tests and an actual browser rendering using a temporary protocol-4 bridge fixture; it has not been requalified on a native device. + +Verification for the full-review corrections: 83 mobile suites / 1,414 tests, 4 focused web/core suites / 92 tests, mobile types and all three web TypeScript configurations passed. New regressions were also run against the old classifier/App/handler behavior and failed as intended. The App suite was repeated after awaiting asynchronous test teardown (73 tests passed). + +Claude re-review confirmed both original issues were addressed and identified two follow-up regressions. These were fixed: Google route viewports are not place pins and search terms survive trailing viewports; empty inboxes skip trip-authority checks, while generic listing failures only show a notice for known shared items. A final import check reports an item missing from an empty inbox as expired rather than trip-deferred. New tests failed before these corrections and passed afterward. Final mobile verification passed 83 suites / 1,416 tests. The focused web/core suite and package types were repeated for the final candidate. + +Final follow-up verification: 4 focused web/core suites / 99 tests passed. The unavailable-authority notice was rendered and inspected at 390 × 844 using a temporary browser-only bridge fixture; it remains below the search controls and uses the application theme. This is web presentation evidence, not native failure injection or native delivery evidence. The fixture and screenshot tooling stayed outside the repository. diff --git a/apps/mobile/qa/webview/compatibility-matrix.md b/apps/mobile/qa/webview/compatibility-matrix.md index 56a2cba51..01155d082 100644 --- a/apps/mobile/qa/webview/compatibility-matrix.md +++ b/apps/mobile/qa/webview/compatibility-matrix.md @@ -1,10 +1,9 @@ # Web / native protocol matrix -The committed shell, web protocol constants and release descriptor support v3 -only. This repository matrix does not establish live deployment history. A page advertising any other range must fail -honestly, and no refused pairing may ever let the browser start a second -navigation engine. A future release may introduce an overlap window once an -older deployed binary actually exists. +The shell and new web client support protocols 3–4. Navigation retains protocol 3; +intake requires selected protocol 4 plus `nativeIntake`. This source matrix does +not establish deployed build history. A non-overlapping pairing disables native +navigation without allowing a second browser engine. ## Where this is enforced @@ -17,37 +16,33 @@ The protocol contracts are covered by: - `packages/core/src/navigation/mobileProtocol.test.ts` covers the current vocabulary and exact request/reply correlation contract. -Run the manual checks on a composed virtual-device build when changing the -protocol. A compatible handshake does not establish that navigation is available: -`src/App.tsx` currently reports every navigation capability false. +The composed shell reports actual navigation and optional module capabilities. ## The matrix -| Native | Web | Handshake | Notes | -| ------ | --------------- | ----------- | ------------------------------------------------------- | -| v3 | v3 | Negotiates | Navigation also requires composed capabilities. | -| v3 | Previous-only | **Refused** | Update required. No browser engine, no partial session. | -| v3 | Future-only | **Refused** | Update required. No browser engine, no partial session. | -| v3 | Malformed hello | **Refused** | Channel stays unhandshaken rather than half-open. | +| Native | Web | Handshake | Behavior | +| ------ | --------------- | --------- | --------------------------------------------------------------------------- | +| 3 | 3–4 | 3 | Existing navigation; no intake commands. | +| 3–4 | 3 | 3 | Navigation and pre-hello snapshots/events use 3; intake capability omitted. | +| 3–4 | 3–4 | 4 | Navigation plus bounded intake when the module is available. | +| 3–4 | No overlap | Refused | Update required; no browser navigation fallback. | +| 3–4 | Malformed hello | Refused | Channel stays unhandshaken. | -## Future version changes - -Do not add compatibility machinery speculatively. Once a v3 binary is deployed, -a later protocol change must define its concrete overlap window, tests, and -deployment order from the capabilities of those real releases. +Deploy compatible web first, then ship rebuilt native apps. Older web builds +retain navigation and receive a native notice about pending content. They do not +drain the inbox. Unopened items expire after 24 hours. ## Manual checks on a virtual device Run these when the protocol changes. Use a development identity for alternate local origins; the official release identity is bound to the official origin. -Active-session checks require a composed dispatcher and remain blocked in the -current app. +Active-session checks require the normal composed dispatcher. - [ ] Point the app at a web build advertising a range below v3. Confirm the update-required copy appears and that Start is disabled. -- [ ] Point it at the current v3 web build. Confirm v3 is selected and the advertised capabilities match actual - composition. The current app must report navigation unavailable. -- [ ] Point it at a web build advertising a range above v3. Confirm the +- [ ] Point it at the current v3–4 web build. Confirm v3 is selected and the advertised capabilities match actual + composition. Capabilities must reflect the composed runtime. +- [ ] Point it at a web build advertising a range above v4. Confirm the update-required copy appears, that Start is disabled, and that browsing and route planning still work. - [ ] Reload the WebView during an active session. Confirm a fresh handshake, @@ -57,3 +52,8 @@ current app. then reload. Confirm the session survives and renegotiates cleanly. - [ ] Confirm that in every refused case above, no browser geolocation watch, no browser voice, and no browser session record appears. + +- [ ] With protocol 3, share nine items: existing navigation remains usable, + content quota stays at eight, and overflow has an explanatory native notice. +- [ ] With protocol 4, reload during a chunk transfer: content remains pending, + stale replies do not reach the next document, and explicit import can retry. diff --git a/apps/mobile/release/version.json b/apps/mobile/release/version.json index 5b9f6da37..98d2bcf0e 100644 --- a/apps/mobile/release/version.json +++ b/apps/mobile/release/version.json @@ -5,9 +5,9 @@ "androidVersionCode": 1, "androidVersionName": "1.0.0", "protocol": { - "$comment": "The protocol implemented by this first release. Future releases may widen this range once a deployed version actually exists.", + "$comment": "Navigation overlaps protocol 3; native inbox commands and capability require protocol 4.", "min": 3, - "max": 3 + "max": 4 }, "minimumWebBuild": null, "channel": "beta" diff --git a/apps/mobile/scripts/assert-no-community-runtime.mts b/apps/mobile/scripts/assert-no-community-runtime.mts index d8c71f9ba..bbb554546 100644 --- a/apps/mobile/scripts/assert-no-community-runtime.mts +++ b/apps/mobile/scripts/assert-no-community-runtime.mts @@ -19,6 +19,7 @@ import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { hasIosOnDeviceGuard } from "./voiceCapturePolicy.ts"; import { hasWebViewCaptureDenial } from "./webViewCapturePolicy.ts"; +import { hasWebViewDocumentStartPolicy } from "./webViewDocumentPolicy.ts"; const repoRoot = resolve(import.meta.dirname, "../../.."); @@ -160,6 +161,16 @@ if (!hasWebViewCaptureDenial(readFileSync(webViewSource, "utf8"))) { }); } +const documentSource = join( + dirname(require.resolve("react-native-webview/package.json")), + "android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java", +); +if (!hasWebViewDocumentStartPolicy(readFileSync(documentSource, "utf8"))) + findings.push({ + file: documentSource, + problem: "installed WebView document-start patch is missing", + }); + const speechSource = join( dirname(require.resolve("expo-speech-recognition/package.json")), "android/src/main/java/expo/modules/speechrecognition/ExpoSpeechService.kt", diff --git a/apps/mobile/scripts/clean-prebuild-check.test.ts b/apps/mobile/scripts/clean-prebuild-check.test.ts index a5e0a5fae..21bb58534 100644 --- a/apps/mobile/scripts/clean-prebuild-check.test.ts +++ b/apps/mobile/scripts/clean-prebuild-check.test.ts @@ -116,6 +116,17 @@ describe("normalizeGeneratedNativeSurface stability", () => { }); describe("normalizeGeneratedNativeSurface sensitivity", () => { + it("hashes the Files-opening value, not only the key's presence", () => { + expect( + hashOf((v) => { + v.infoPlist.LSSupportsOpeningDocumentsInPlace = true; + }), + ).not.toBe( + hashOf((v) => { + v.infoPlist.LSSupportsOpeningDocumentsInPlace = false; + }), + ); + }); it.each([ [ "an added permission", diff --git a/apps/mobile/scripts/generatedNativeChecks.test.ts b/apps/mobile/scripts/generatedNativeChecks.test.ts index 7b888f1b0..ac3fd0a55 100644 --- a/apps/mobile/scripts/generatedNativeChecks.test.ts +++ b/apps/mobile/scripts/generatedNativeChecks.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { AndroidManifestLike } from "../plugins/nativeConfigTransforms"; import { REQUIRED_ANDROID_PERMISSIONS } from "../plugins/nativeConfigTransforms"; +import { + intakeDocumentTypes, + intakeReceiverDeclaration, + intakeShareInfo, +} from "../plugins/withNativeIntake"; import { checkGeneratedNativeSurface, type ExpectedNativeSurface, @@ -53,6 +58,7 @@ function androidManifest(): AndroidManifestLike { "android:allowBackup": "false", "android:networkSecurityConfig": "@xml/network_security_config", }, + activity: [intakeReceiverDeclaration()], "meta-data": [ { $: { "android:name": "expo.modules.updates.ENABLED", "android:value": "false" } }, ], @@ -64,12 +70,31 @@ function androidManifest(): AndroidManifestLike { function surface(): GeneratedNativeSurface { return { + shareExtension: { + infoPlist: intakeShareInfo("org.openmapx.app"), + entitlements: { "com.apple.security.application-groups": ["group.org.openmapx.app.intake"] }, + privacy: { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp", + NSPrivacyAccessedAPITypeReasons: ["C617.1", "3B52.1"], + }, + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategorySystemBootTime", + NSPrivacyAccessedAPITypeReasons: ["35F9.1"], + }, + ], + }, + }, navigationExtension: { NSExtension: { NSExtensionPointIdentifier: "com.apple.widgetkit-extension" }, CFBundleVersion: "$(CURRENT_PROJECT_VERSION)", CFBundleShortVersionString: "$(MARKETING_VERSION)", }, infoPlist: { + LSSupportsOpeningDocumentsInPlace: true, + CFBundleDocumentTypes: intakeDocumentTypes(), + OpenMapXIntakeAppGroup: "group.org.openmapx.app.intake", NSMicrophoneUsageDescription: "OpenMapX uses the microphone for voice search when you tap the microphone button.", NSSpeechRecognitionUsageDescription: @@ -94,13 +119,14 @@ function surface(): GeneratedNativeSurface { NSFileProtectionKey: "NSFileProtectionCompleteUntilFirstUserAuthentication", }, entitlements: { + "com.apple.security.application-groups": ["group.org.openmapx.app.intake"], "com.apple.developer.associated-domains": [ "applinks:openmapx.com", "webcredentials:openmapx.com", ], }, pbxproj: - "PRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app;\nPRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app.NavigationSurface;\nIPHONEOS_DEPLOYMENT_TARGET = 16.4;\nAPPLICATION_EXTENSION_API_ONLY = YES;\nOpenMapXNavigationSurface.appex in Copy Files\n", + "PRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app;\nPRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app.NavigationSurface;\nPRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app.ShareExtension;\nOpenMapXShareExtension.appex in Copy Files\nOpenMapXShareExtension/ShareExtension.entitlements\nIPHONEOS_DEPLOYMENT_TARGET = 16.4;\nAPPLICATION_EXTENSION_API_ONLY = YES;\nOpenMapXNavigationSurface.appex in Copy Files\n", podfileProperties: { "ios.deploymentTarget": "16.4" }, androidManifest: androidManifest(), networkSecurityConfig: '', @@ -144,7 +170,7 @@ describe("iOS policy", () => { value.pbxproj += "PRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app.extra;\n"; }), ).toContainEqual( - expect.stringContaining("exactly app and navigation extension bundle identifiers"), + expect.stringContaining("exactly app, navigation and share extension bundle identifiers"), ); }); @@ -375,7 +401,7 @@ describe("storage and component exposure", () => { ).toContainEqual(expect.stringContaining("NSFileProtectionComplete")); }); - it.each(["UIFileSharingEnabled", "LSSupportsOpeningDocumentsInPlace"])( + it.each(["UIFileSharingEnabled", "UISupportsDocumentBrowser"])( "rejects %s, which would expose the session database", (key) => { expect( @@ -431,6 +457,7 @@ describe("storage and component exposure", () => { activity?: Array<{ $: Record; "intent-filter"?: unknown[] }>; }; application.activity = [ + ...(application.activity ?? []), { $: { "android:name": ".MainActivity", "android:exported": "true" }, "intent-filter": [{}], @@ -449,6 +476,82 @@ it("requires the generated ActivityKit support flag", () => { ); }); +it("rejects intake groups leaking to the navigation widget or broad extension entitlements", () => { + const widget = surface(); + widget.pbxproj += + "\nbuildSettings = { PRODUCT_BUNDLE_IDENTIFIER = org.openmapx.app.NavigationSurface; CODE_SIGN_ENTITLEMENTS = Other.entitlements; };"; + expect(checkGeneratedNativeSurface(widget, EXPECTED)).toContainEqual( + expect.stringContaining("navigation extension must not receive"), + ); + const broad = surface(); + if (!broad.shareExtension) throw new Error("missing fixture"); + broad.shareExtension.entitlements["aps-environment"] = "development"; + expect(checkGeneratedNativeSurface(broad, EXPECTED)).toContainEqual( + expect.stringContaining("exactly the intake App Group"), + ); +}); +it("requires explicit opening-in-place true and a real extension privacy manifest", () => { + const allowed = surface(); + expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toEqual([]); + allowed.infoPlist.LSSupportsOpeningDocumentsInPlace = false; + expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toContainEqual( + expect.stringContaining("explicitly true"), + ); + const missing = surface(); + if (!missing.shareExtension) throw new Error("missing fixture"); + missing.shareExtension.privacy = {}; + expect(checkGeneratedNativeSurface(missing, EXPECTED)).toContainEqual( + expect.stringContaining("privacy manifest"), + ); +}); +it("rejects missing selected-file metadata or deadline privacy reasons", () => { + const value = surface(); + if (!value.shareExtension) throw new Error("missing fixture"); + value.shareExtension.privacy = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp", + NSPrivacyAccessedAPITypeReasons: ["C617.1"], + }, + ], + }; + expect(checkGeneratedNativeSurface(value, EXPECTED)).toContainEqual( + expect.stringContaining("privacy manifest"), + ); +}); +it("rejects broad share activation rules", () => { + const value = surface(); + if (!value.shareExtension) throw new Error("missing fixture"); + value.shareExtension.infoPlist.NSExtension = { + NSExtensionPointIdentifier: "com.apple.share-services", + NSExtensionAttributes: { NSExtensionActivationRule: "TRUEPREDICATE" }, + }; + expect(checkGeneratedNativeSurface(value, EXPECTED)).toContainEqual( + expect.stringContaining("bounded geographic activation"), + ); +}); + +it("keeps intake out of the React task and receiver out of history", () => { + const value = surface(); + const app = value.androidManifest.manifest.application?.[0]; + if (!app) throw new Error("missing fixture"); + const activities = app.activity as Array<{ + $: Record; + "intent-filter"?: unknown[]; + }>; + activities[0].$["android:noHistory"] = "false"; + expect(checkGeneratedNativeSurface(value, EXPECTED)).toContainEqual( + expect.stringContaining("nonhistorical activity"), + ); + activities.push({ + $: { "android:name": ".MainActivity", "android:exported": "true" }, + "intent-filter": [{ action: [{ $: { "android:name": "android.intent.action.SEND" } }] }], + }); + expect(checkGeneratedNativeSurface(value, EXPECTED)).toContainEqual( + expect.stringContaining("never the React task"), + ); +}); + it("requires speech service visibility", () => { expect( failuresAfter((value) => { diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index a20221e8f..21fc3a06f 100644 --- a/apps/mobile/scripts/generatedNativeChecks.ts +++ b/apps/mobile/scripts/generatedNativeChecks.ts @@ -1,3 +1,4 @@ +import { INTAKE_RECEIVER } from "../config/nativeIntakeConfig.ts"; /** * Policy checks over the *generated* native projects. * @@ -35,6 +36,11 @@ export interface ExpectedNativeSurface { export interface GeneratedNativeSurface { infoPlist: Record; navigationExtension?: Record; + shareExtension?: { + infoPlist: Record; + entitlements: Record; + privacy: Record; + }; entitlements: Record; /** Raw `project.pbxproj` text. */ pbxproj: string; @@ -131,10 +137,14 @@ function checkIos( ), ), ]; - const expectedIds = [expected.appId, `${expected.appId}.NavigationSurface`]; - if (bundleIds.length !== 2 || expectedIds.some((id) => !bundleIds.includes(id))) { + const expectedIds = [ + expected.appId, + `${expected.appId}.NavigationSurface`, + `${expected.appId}.ShareExtension`, + ]; + if (bundleIds.length !== 3 || expectedIds.some((id) => !bundleIds.includes(id))) { fail( - `expected exactly app and navigation extension bundle identifiers: ${expectedIds.join(", ")}`, + `expected exactly app, navigation and share extension bundle identifiers: ${expectedIds.join(", ")}`, ); } @@ -204,6 +214,68 @@ function checkIos( if (infoPlist.NSFileProtectionKey !== IOS_DATA_PROTECTION_CLASS) { fail(`the app container must declare ${IOS_DATA_PROTECTION_CLASS}`); } + if (infoPlist.LSSupportsOpeningDocumentsInPlace !== true) + fail("LSSupportsOpeningDocumentsInPlace must be explicitly true"); + const group = `group.${expected.appId}.intake`; + const share = generated.shareExtension; + if ( + JSON.stringify(entitlements["com.apple.security.application-groups"]) !== + JSON.stringify([group]) || + JSON.stringify(share?.entitlements) !== + JSON.stringify({ "com.apple.security.application-groups": [group] }) + ) + fail("host and share extension must have exactly the intake App Group"); + const sharePoint = asObject(share?.infoPlist.NSExtension); + const activation = asObject(sharePoint.NSExtensionAttributes).NSExtensionActivationRule; + if ( + sharePoint.NSExtensionPointIdentifier !== "com.apple.share-services" || + sharePoint.NSExtensionPrincipalClass !== "$(PRODUCT_MODULE_NAME).ShareViewController" || + typeof activation !== "string" || + !activation.includes("SUBQUERY") || + activation.includes("TRUEPREDICATE") || + activation.includes("SupportsFileWithMaxCount") || + share?.infoPlist.OpenMapXIntakeAppGroup !== group || + infoPlist.OpenMapXIntakeAppGroup !== group + ) + fail("share extension must use the bounded geographic activation predicate and derived group"); + if ( + !generated.pbxproj.includes("OpenMapXShareExtension.appex in Copy Files") || + !generated.pbxproj.includes("OpenMapXShareExtension/ShareExtension.entitlements") + ) + fail("share extension must be embedded with its own entitlement file"); + const accessed = asObject(share?.privacy).NSPrivacyAccessedAPITypes; + const reasons = (category: string, required: string[]) => + Array.isArray(accessed) && + accessed.some((entry) => { + const value = asObject(entry); + const declared = value.NSPrivacyAccessedAPITypeReasons; + return ( + value.NSPrivacyAccessedAPIType === category && + Array.isArray(declared) && + required.every((reason) => declared.includes(reason)) + ); + }); + if ( + !reasons("NSPrivacyAccessedAPICategoryFileTimestamp", ["C617.1", "3B52.1"]) || + !reasons("NSPrivacyAccessedAPICategorySystemBootTime", ["35F9.1"]) + ) + fail( + "share extension privacy manifest must declare local/selected file metadata and deadline reasons", + ); + for (const block of generated.pbxproj.matchAll(/buildSettings = \{([\s\S]*?)\};/g)) { + if ( + block[1].includes(`${expected.appId}.NavigationSurface`) && + block[1].includes("CODE_SIGN_ENTITLEMENTS") + ) + fail("navigation extension must not receive intake group entitlements"); + } + const documents = infoPlist.CFBundleDocumentTypes; + if ( + !Array.isArray(documents) || + JSON.stringify(asObject(documents[0]).LSItemContentTypes) !== + JSON.stringify(["com.topografix.gpx", "public.geojson", "com.google.earth.kml"]) + ) + fail("document types must be exactly GPX, GeoJSON and KML"); for (const key of FORBIDDEN_IOS_SHARING_KEYS) { if (key in infoPlist) fail(`${key} would expose the active session database`); } @@ -316,6 +388,25 @@ function checkAndroid( $: Record; "intent-filter"?: unknown[]; }>; + const intake = activities.filter((a) => a.$["android:name"] === INTAKE_RECEIVER); + if ( + intake.length !== 1 || + intake[0].$["android:noHistory"] !== "true" || + intake[0].$["android:excludeFromRecents"] !== "true" || + intake[0].$["android:taskAffinity"] !== "" || + intake[0].$["android:launchMode"] !== "standard" + ) + fail("intake receiver must be exactly one nonhistorical activity with no task affinity"); + for (const activity of activities.filter((a) => a.$["android:name"] !== INTAKE_RECEIVER)) { + if ( + (activity["intent-filter"] ?? []).some((filter) => + /android.intent.action.SEND|"android:scheme":"content"/.test(JSON.stringify(filter)), + ) + ) + fail( + "share and content intents must target only the intake receiver, never the React task", + ); + } for (const activity of activities) { if ( activity.$["android:exported"] === "true" && diff --git a/apps/mobile/scripts/normalizeGeneratedNative.ts b/apps/mobile/scripts/normalizeGeneratedNative.ts index 3241ef9f8..d89c9f700 100644 --- a/apps/mobile/scripts/normalizeGeneratedNative.ts +++ b/apps/mobile/scripts/normalizeGeneratedNative.ts @@ -28,6 +28,7 @@ export interface NormalizedNativeSurface { exceptionDomains: string[]; dataProtection: unknown; sharingKeys: string[]; + openingDocumentsInPlace: boolean | null; }; android: { permissions: string[]; @@ -97,10 +98,14 @@ export function normalizeGeneratedNativeSurface( Object.keys((transport.NSExceptionDomains ?? {}) as Record), ), dataProtection: plist.NSFileProtectionKey ?? null, - // Present only if something regressed; tracked so it would change the hash. + openingDocumentsInPlace: + typeof plist.LSSupportsOpeningDocumentsInPlace === "boolean" + ? plist.LSSupportsOpeningDocumentsInPlace + : null, + // These keys expose app documents, unlike opening a user-selected file. sharingKeys: sortedUnique( Object.keys(plist).filter( - (key) => key === "UIFileSharingEnabled" || key === "LSSupportsOpeningDocumentsInPlace", + (key) => key === "UIFileSharingEnabled" || key === "UISupportsDocumentBrowser", ), ), }, diff --git a/apps/mobile/scripts/readGeneratedNative.ts b/apps/mobile/scripts/readGeneratedNative.ts index 4029edc71..0a6e6728e 100644 --- a/apps/mobile/scripts/readGeneratedNative.ts +++ b/apps/mobile/scripts/readGeneratedNative.ts @@ -125,6 +125,17 @@ export async function readGeneratedNativeSurface( navigationExtension: plist.parse( readText(join(mobileRoot, "ios/OpenMapXNavigationSurface/Info.plist")), ) as Record, + shareExtension: { + infoPlist: plist.parse( + readText(join(mobileRoot, "ios/OpenMapXShareExtension/Info.plist")), + ) as Record, + entitlements: plist.parse( + readText(join(mobileRoot, "ios/OpenMapXShareExtension/ShareExtension.entitlements")), + ) as Record, + privacy: plist.parse( + readText(join(mobileRoot, "ios/OpenMapXShareExtension/PrivacyInfo.xcprivacy")), + ) as Record, + }, pbxproj: readText(iosProjectFile(mobileRoot)), podfileProperties: JSON.parse(readText(join(mobileRoot, "ios/Podfile.properties.json"))), androidManifest, diff --git a/apps/mobile/scripts/run-native-tests.mts b/apps/mobile/scripts/run-native-tests.mts index 11e1fd8fb..b810660c8 100644 --- a/apps/mobile/scripts/run-native-tests.mts +++ b/apps/mobile/scripts/run-native-tests.mts @@ -41,6 +41,7 @@ function runSwiftTests(): void { console.log("\n[native-tests] swift test — navigation audio policy"); run("swift", ["test"], audioModule); run("swift", ["test"], join(mobileRoot, "modules/openmapx-navigation-surfaces")); + run("swift", ["test"], join(mobileRoot, "modules/openmapx-intake")); } /** @@ -87,6 +88,7 @@ function runKotlinTests(): void { [ ":openmapx-navigation-audio:testDebugUnitTest", ":openmapx-navigation-surfaces:testDebugUnitTest", + ":openmapx-intake:testDebugUnitTest", ":openmapx-android-auto:testDebugUnitTest", "--no-daemon", ], diff --git a/apps/mobile/scripts/webViewDocumentPolicy.test.ts b/apps/mobile/scripts/webViewDocumentPolicy.test.ts new file mode 100644 index 000000000..0a4d215e9 --- /dev/null +++ b/apps/mobile/scripts/webViewDocumentPolicy.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { hasWebViewDocumentStartPolicy } from "./webViewDocumentPolicy"; + +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/RNCWebViewClient.java", + ), + "utf8", +); + +it("installed WebView distinguishes document commits from history before page injection", () => { + expect(hasWebViewDocumentStartPolicy(installed)).toBe(true); +}); +it.each([ + ['"openmapxDocumentStart", true', '"openmapxDocumentStart", false'], + ['"openmapxDocumentStart", false', '"openmapxDocumentStart", true'], + ["new TopLoadingStartEvent(", "new OtherEvent("], + ["callInjectedJavaScriptBeforeContentLoaded()", "missingInjection()"], +])("rejects broken native lifecycle wiring: %s", (before, after) => { + expect(hasWebViewDocumentStartPolicy(installed.replace(before, after))).toBe(false); +}); diff --git a/apps/mobile/scripts/webViewDocumentPolicy.ts b/apps/mobile/scripts/webViewDocumentPolicy.ts new file mode 100644 index 000000000..1c9b7d4b1 --- /dev/null +++ b/apps/mobile/scripts/webViewDocumentPolicy.ts @@ -0,0 +1,21 @@ +/** Check the installed SDK's event origin, including ordering before page code. */ +export function hasWebViewDocumentStartPolicy(source: string): boolean { + const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); + const method = (name: string) => + (code.split(`public void ${name} (`)[1] ?? code.split(`public void ${name}(`)[1] ?? "") + .split("@Override")[0] + .replace(/\s+/g, " "); + const start = method("onPageStarted"); + const history = method("doUpdateVisitedHistory"); + const taggedBeforeDelivery = (body: string, tag: boolean) => { + const flag = body.indexOf(`event.putBoolean("openmapxDocumentStart", ${tag});`); + const delivery = body.indexOf("new TopLoadingStartEvent("); + return flag >= 0 && delivery > flag && body.slice(delivery).includes(", event)"); + }; + return ( + taggedBeforeDelivery(start, true) && + start.indexOf("callInjectedJavaScriptBeforeContentLoaded()") > + start.indexOf("new TopLoadingStartEvent(") && + taggedBeforeDelivery(history, false) + ); +} diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 97e7d1542..934621883 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -4,10 +4,12 @@ jest.mock("expo-speech-recognition", () => ({ 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 { Alert, AppState, BackHandler, Linking, Platform } from "react-native"; import { z } from "zod"; import { App } from "./App"; import { resetRuntimeConfigCache } from "./config/runtimeConfig"; +import { IntakeController } from "./intake/IntakeController"; +import * as intakeModule from "./intake/nativeIntakeModule"; import { groundSessionFixture } from "./storage/testing/sessionFixture"; import { VoiceInputController } from "./voice/VoiceInputController"; @@ -120,7 +122,7 @@ describe("App WebView policy", () => { it("renders exactly one WebView pointed at the compiled origin", async () => { const view = await renderApp(); expect(view.getByTestId("product-webview")).toBeTruthy(); - expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com" }); + expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com/" }); }); it("forwards app visibility and reapplies it when a document finishes loading", async () => { @@ -253,7 +255,7 @@ describe("App bridge channel", () => { const warm = decodedMessages().filter((m) => m.type === "deep-link.open"); expect(warm).toHaveLength(2); expect(new URLSearchParams(warm[1].payload?.query as string).get("at")).toBe("52.52,13.405"); - expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com" }); + expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com/" }); }); it("does not deliver a pending link to an incompatible bridge", async () => { @@ -310,9 +312,12 @@ describe("App bridge channel", () => { }); it("keeps the negotiated channel across Android's same-document history reports", async () => { + jest.replaceProperty(Platform, "OS", "android"); await renderApp(); const nonce = nonceIn(bootstrap()); - await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { loading: true } }); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: true }, + }); await post(nonce, { type: "web.hello", payload: { @@ -322,20 +327,205 @@ describe("App bridge channel", () => { }, }); await emitAsync(mockWebViewProps.onLoadEnd); + // An allowed request may produce a download/204 and never commit. + await emitAsync(mockWebViewProps.onShouldStartLoadWithRequest, { + url: "https://openmapx.com/download", + }); // `replaceState` after the page finished: Android reports it as a load // start with `loading: false`. - await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { loading: false } }); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: false }, + }); await post(nonce, { - messageId: "prepare-after-history", - type: "session.prepare", - payload: { startPackage: groundSessionFixture().payload.startPackage }, + messageId: "probe-after-history", + type: "voice.probe", + payload: {}, }); // Still the page's channel: the command reaches dispatch and is answered, // rather than being dropped as coming from an unknown document. - const error = decodedMessages().find((message) => message.type === "native.error"); - expect(error?.payload).toMatchObject({ forMessageId: "prepare-after-history" }); + const reply = decodedMessages().find((message) => message.type === "voice.availability"); + expect(reply?.payload).toMatchObject({ forMessageId: "probe-after-history" }); + }); + + it("ignores duplicate Android start/end reports without losing the page channel", async () => { + jest.replaceProperty(Platform, "OS", "android"); + await renderApp(); + const nonce = nonceIn(bootstrap()); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: true }, + }); + await post(nonce, { + type: "web.hello", + payload: { + webBuildId: "lifecycle-test", + minProtocolVersion: 3, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + // Android can report history while the real document is still loading. + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: false }, + }); + await post(nonce, { + messageId: "probe-after-duplicate-start", + type: "voice.probe", + payload: {}, + }); + expect( + decodedMessages().some( + (message) => + message.type === "voice.availability" && + message.payload?.forMessageId === "probe-after-duplicate-start", + ), + ).toBe(true); + await emitAsync(mockWebViewProps.onLoadEnd); + const next = nonceIn(bootstrap()); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: false }, + }); + await emitAsync(mockWebViewProps.onLoadEnd); + expect(nonceIn(bootstrap())).toBe(next); + await emitAsync(mockWebViewProps.onLoadEnd); + expect(nonceIn(bootstrap())).toBe(next); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: true, openmapxDocumentStart: true }, + }); + await post(next, { + messageId: "hello-real-reload", + type: "web.hello", + payload: { + webBuildId: "reloaded", + minProtocolVersion: 3, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + expect( + decodedMessages().some( + (message) => + message.type === "native.hello" && message.payload?.forMessageId === "hello-real-reload", + ), + ).toBe(true); + }); + + it.each(["android", "ios"] as const)( + "resets document state for an allowed %s navigation during an unfinished load", + async (platform) => { + jest.replaceProperty(Platform, "OS", platform); + const cancel = jest + .spyOn(VoiceInputController.prototype, "cancel") + .mockImplementation(() => {}); + const nonce = await hello(); + cancel.mockClear(); + await emitAsync(mockWebViewProps.onShouldStartLoadWithRequest, { + url: "https://openmapx.com/settings", + isTopFrame: true, + }); + await emitAsync(mockWebViewProps.onLoadStart, { + nativeEvent: { loading: false, openmapxDocumentStart: true }, + }); + expect(cancel).toHaveBeenCalledTimes(1); + await post(nonce, { messageId: "obsolete-document", type: "voice.probe", payload: {} }); + expect(decodedMessages().some((m) => m.payload?.forMessageId === "obsolete-document")).toBe( + false, + ); + // The in-flight script still has its published nonce, but its old + // handshake and dedupe state must be gone. A fresh hello can reuse its ID. + await post(nonce, { + type: "web.hello", + payload: { + webBuildId: "replacement", + minProtocolVersion: 3, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + expect(decodedMessages().filter((m) => m.type === "native.hello")).toHaveLength(2); + }, + ); + + it("does not suppress iOS document starts even without an Android navigation flag", async () => { + jest.replaceProperty(Platform, "OS", "ios"); + const cancel = jest + .spyOn(VoiceInputController.prototype, "cancel") + .mockImplementation(() => {}); + const nonce = await hello(); + cancel.mockClear(); + await emitAsync(mockWebViewProps.onLoadStart); + expect(cancel).toHaveBeenCalledTimes(1); + await post(nonce, { messageId: "old-ios-document", type: "voice.probe", payload: {} }); + expect(decodedMessages().some((m) => m.payload?.forMessageId === "old-ios-document")).toBe( + false, + ); + }); + + it("rejects the previous document nonce after a completed load and real reload", async () => { + jest.replaceProperty(Platform, "OS", "android"); + const oldNonce = await hello(); + await emitAsync(mockWebViewProps.onLoadEnd); + const nextNonce = nonceIn(bootstrap()); + expect(nextNonce).not.toBe(oldNonce); + await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { openmapxDocumentStart: true } }); + await post(nextNonce, { + messageId: "reload-hello", + type: "web.hello", + payload: { + webBuildId: "new-document", + minProtocolVersion: 3, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + await post(oldNonce, { messageId: "old-nonce", type: "voice.probe", payload: {} }); + expect(decodedMessages().some((m) => m.payload?.forMessageId === "old-nonce")).toBe(false); + await post(nextNonce, { messageId: "new-nonce", type: "voice.probe", payload: {} }); + expect(decodedMessages()).toContainEqual( + expect.objectContaining({ + type: "voice.availability", + payload: { forMessageId: "new-nonce", available: false }, + }), + ); + }); + + it("fails closed when Android JS is paired with a stale APK, logging only once", async () => { + jest.replaceProperty(Platform, "OS", "android"); + const error = jest.spyOn(console, "error").mockImplementation(() => {}); + await renderApp(); + const nonce = nonceIn(bootstrap()); + await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { loading: true } }); + await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { loading: true } }); + await post(nonce, { + type: "web.hello", + payload: { + webBuildId: "stale-apk", + minProtocolVersion: 3, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + expect(decodedMessages()).toEqual([]); + expect(error).toHaveBeenCalledTimes(1); + }); + + it("does not let a history finish erase a failed load or rotate its nonce", async () => { + jest.replaceProperty(Platform, "OS", "android"); + const view = await renderApp(); + const nonce = nonceIn(bootstrap()); + await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { openmapxDocumentStart: true } }); + await emitAsync(mockWebViewProps.onError); + const retryNonce = nonceIn(bootstrap()); + expect(retryNonce).not.toBe(nonce); + await emitAsync(mockWebViewProps.onLoadEnd); + expect(nonceIn(bootstrap())).toBe(retryNonce); + expect(view.queryByTestId("shell-loading")).toBeNull(); + expect(view.getByTestId("shell-state-load-error")).toBeTruthy(); + }); + + it("uses a defensive canonical source object across shell renders", async () => { + await renderApp(); + const source = mockWebViewProps.source; + expect(source).toEqual({ uri: "https://openmapx.com/" }); + await emitAsync(mockWebViewProps.onLoadStart); + await emitAsync(mockWebViewProps.onLoadEnd); + expect(mockWebViewProps.source).toBe(source); }); it("ignores a message from outside the compiled origin", async () => { @@ -408,27 +598,98 @@ describe("App bridge channel", () => { }); let shellView: Awaited>; - const hello = async () => { + const hello = async (maxProtocolVersion = MOBILE_PROTOCOL_MAX) => { shellView = await renderApp(); const nonce = nonceIn(bootstrap()); - await emitAsync(mockWebViewProps.onLoadStart); + await emitAsync(mockWebViewProps.onLoadStart, { nativeEvent: { openmapxDocumentStart: true } }); await post(nonce, { type: "web.hello", + protocolVersion: maxProtocolVersion, payload: { webBuildId: "web-build-1", minProtocolVersion: 1, - maxProtocolVersion: MOBILE_PROTOCOL_MAX, + maxProtocolVersion, }, }); return nonce; }; - it("advertises what the composed navigation graph reports", async () => { + it.each(["failed", "feasibility", "idle", "active", "storage-failed"])( + "checks intake authority when navigation is %s", + async (state) => { + const complete = jest.fn(); + jest.spyOn(intakeModule, "createNativeIntakePort").mockReturnValue({ + available: async () => true, + list: async () => [ + { + id: "f386e706-b728-40a3-9554-881d0ff0958a", + kind: "text", + name: "Shared location", + createdAtMs: 1, + byteLength: 6, + }, + ], + read: jest.fn(), + complete, + listen: () => () => undefined, + }); + if (state === "feasibility") + mockManifest = { ...RELEASE_MANIFEST, release: false, feasibilityMode: true }; + if (["idle", "active", "storage-failed"].includes(state)) + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + if (state === "active") + mockNavigation.activeSession.mockResolvedValue({ sessionId: "active-trip" }); + const nonce = await hello(); + if (state === "storage-failed") + mockNavigation.activeSession.mockRejectedValue(new Error("storage unavailable")); + await post(nonce, { messageId: "list-authority", type: "intake.list", payload: {} }); + const reply = decodedMessages().find((m) => m.payload?.forMessageId === "list-authority"); + if (state === "failed" || state === "storage-failed") { + expect(reply).toMatchObject({ + type: "native.error", + payload: { code: "navigation-unavailable" }, + }); + } else { + expect(reply).toMatchObject({ + type: "intake.items", + payload: { canApply: state !== "active" }, + }); + } + if (["failed", "storage-failed", "active"].includes(state)) { + await post(nonce, { + messageId: "complete-authority", + type: "intake.complete", + payload: { id: "f386e706-b728-40a3-9554-881d0ff0958a", disposition: "opened" }, + }); + expect(complete).not.toHaveBeenCalled(); + } + await shellView.unmount(); + }, + ); + + it("keeps an empty inbox quiet when navigation initialization failed", async () => { + jest.spyOn(intakeModule, "createNativeIntakePort").mockReturnValue({ + available: async () => true, + list: async () => [], + read: jest.fn(), + complete: jest.fn(), + listen: () => () => undefined, + }); + const nonce = await hello(); + await post(nonce, { messageId: "empty-unavailable", type: "intake.list", payload: {} }); + expect( + decodedMessages().find((m) => m.payload?.forMessageId === "empty-unavailable"), + ).toMatchObject({ type: "intake.items", payload: { items: [], canApply: false } }); + await shellView.unmount(); + }); + + it("keeps the deployed v3 capability schema while advertising the navigation graph", async () => { mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); - await hello(); + await hello(3); const reply = decodedMessages().find((message) => message.type === "native.hello"); - // origin/main capability fixture: deployed web rejects every unknown key. + expect(reply?.payload?.selectedProtocolVersion).toBe(3); + // Deployed v3 web rejects every unknown capability key. const oldCapabilities = z .object({ groundNavigation: z.boolean(), @@ -449,6 +710,24 @@ describe("App bridge channel", () => { }); }); + it("dispatches intake and voice independently of the navigation command queue", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + const intake = jest.spyOn(IntakeController.prototype, "dispatch").mockResolvedValue(); + jest.spyOn(VoiceInputController.prototype, "isAvailable").mockReturnValue(false); + const nonce = await hello(); + mockNavigation.coordinator.dispatch.mockImplementation(() => new Promise(() => {})); + await post(nonce, { messageId: "intake-1", type: "intake.list", payload: {} }); + await post(nonce, { messageId: "probe-after-intake", type: "voice.probe", payload: {} }); + expect(intake).toHaveBeenCalledWith(expect.objectContaining({ type: "intake.list" })); + expect(decodedMessages()).toContainEqual( + expect.objectContaining({ + type: "voice.availability", + payload: { forMessageId: "probe-after-intake", available: false }, + }), + ); + expect(mockNavigation.coordinator.dispatch).not.toHaveBeenCalled(); + }); + it.each([true, false])( "answers voice probes with availability %s outside navigation", async (available) => { @@ -626,12 +905,13 @@ describe("App load states", () => { expect(view.queryByTestId("shell-loading")).toBeNull(); }); - it("does not cover a loaded page again for a same-document history change", async () => { + it("does not cover a loaded Android page again for a same-document history change", async () => { + jest.replaceProperty(Platform, "OS", "android"); const view = await renderApp(); await emit(mockWebViewProps.onLoadEnd); await act(async () => { (mockWebViewProps.onLoadStart as (event: unknown) => void)({ - nativeEvent: { loading: false }, + nativeEvent: { loading: false, openmapxDocumentStart: false }, }); }); expect(view.queryByTestId("shell-loading")).toBeNull(); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 0131046ec..37b6c75db 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,8 @@ import { createShellBridge, type ShellBridge } from "./bridge/createShellBridge" import { buildChannelBootstrapScript } from "./bridge/outboundScript"; import { getRuntimeConfig, type MobileRuntimeConfig } from "./config/runtimeConfig"; import { useConnectivity } from "./connectivity/useConnectivity"; +import { IntakeController } from "./intake/IntakeController"; +import { createNativeIntakePort, NativeIntakeError } from "./intake/nativeIntakeModule"; import { createNavigationRecoveryController } from "./lifecycle/createNavigationRecoveryController"; import { useAppVisibility } from "./lifecycle/useAppLifecycle"; import { buildWebVisibilityScript } from "./lifecycle/webVisibility"; @@ -114,6 +116,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement subscription.remove(); }; }, [voiceIntent]); + const webSource = useMemo(() => ({ uri: new URL(config.webOrigin).href }), [config.webOrigin]); const handleNavigationStateChange = useHardwareBack(webViewRef); useEffect(() => { webViewRef.current?.injectJavaScript(buildWebVisibilityScript(visibility)); @@ -142,7 +145,9 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement navigatingRef.current = navigating; dispatchShell({ type: navigating ? "session-started" : "session-ended" }); }, []); + const intakePort = useMemo(() => createNativeIntakePort(), []); const { shell, navigation, voice } = useMemo(() => { + let intake: IntakeController; let navigation: Promise = Promise.resolve(null); const voice = new VoiceInputController({ module: ExpoSpeechRecognitionModule, @@ -161,6 +166,10 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement // 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.startsWith("intake.")) { + await intake.dispatch(message); + return; + } if (message.type === "voice.probe") { composed.bridge.send( "voice.availability", @@ -200,7 +209,10 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement composed.bridge.send(type, payload, options), ); }, - capabilities: async () => (await navigation)?.capabilities() ?? NO_NAVIGATION, + capabilities: async () => ({ + ...((await navigation)?.capabilities() ?? NO_NAVIGATION), + nativeIntake: await intakePort.available(), + }), permission: () => services.permission(), activeSession: async () => (await navigation)?.activeSession() ?? null, }); @@ -215,8 +227,26 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement onNavigatingChanged: setNavigating, }).catch(() => null); } - return { shell: composed, navigation, voice }; - }, [config, locale, prompter, services, setNavigating, voiceIntent]); + intake = new IntakeController({ + port: intakePort, + scheme: config.scheme, + currentNonce: () => composed.registry.current()?.nonce, + send: (type, payload) => composed.bridge.send(type, payload), + canApply: async () => { + const runtime = await navigation; + if (runtime === null) { + if (config.feasibilityMode) return true; + throw new NativeIntakeError("navigation-unavailable"); + } + try { + return (await runtime.activeSession()) === null; + } catch { + throw new NativeIntakeError("navigation-unavailable"); + } + }, + }); + return { shell: composed, navigation, intake, voice }; + }, [config, locale, prompter, services, setNavigating, intakePort, voiceIntent]); useEffect(() => { if (visibility !== "active") voice.suspend(); }, [visibility, voice]); @@ -280,20 +310,33 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement /** * The nonce the document-start script currently publishes. * - * It is rotated only after a load finishes, never while one is in flight: the + * It is rotated after a load finishes or fails, never while one is in flight: the * script that carries it runs as the document is created, so changing it * mid-navigation would hand the page a value the shell no longer accepts. */ const [publishedNonce, setPublishedNonce] = useState(() => shell.registry.mintNonce()); + const documentInFlight = useRef(true); + const missingDocumentTagReported = useRef(false); const handleLoadStart = useCallback( - (event?: { nativeEvent?: { loading?: boolean } }) => { - // Android also reports every same-document history change (`pushState`, - // `replaceState`) as a load start, from `doUpdateVisitedHistory`, with - // `loading: false` because the document has already finished. That is not - // a new document: adopting the next nonce there would orphan the page's - // negotiated channel and put the loading screen back over a working page. - if (event?.nativeEvent?.loading === false) return; + (event?: { nativeEvent?: { url?: string; openmapxDocumentStart?: boolean } }) => { + // Android history callbacks can report loading=true even after a finish. + // Only the patched native onPageStarted event identifies a new document. + // A stale development APK must fail closed, never guess from progress. + if (Platform.OS === "android" && event?.nativeEvent?.openmapxDocumentStart !== true) { + if ( + __DEV__ && + event?.nativeEvent?.openmapxDocumentStart === undefined && + !missingDocumentTagReported.current + ) { + missingDocumentTagReported.current = true; + console.error( + "OpenMapX document-start tag missing; rebuild the Android development app.", + ); + } + return; + } + documentInFlight.current = true; // A real top-level navigation or reload. // // Navigation and its location driver survive a reload. Voice input is @@ -308,6 +351,10 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement ); const handleLoadEnd = useCallback(() => { + // iOS emits load-end for pushState/replaceState too. Only the first end + // after an actual start prepares the next document's bootstrap nonce. + if (!documentInFlight.current) return; + documentInFlight.current = false; // A load can finish while backgrounded; lifecycle delivery to the previous // document must not leave this document thinking the app is visible. webViewRef.current?.injectJavaScript(buildWebVisibilityScript(visibilityRef.current)); @@ -317,13 +364,66 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement }, [shell]); const handleLoadFailure = useCallback(() => { + documentInFlight.current = false; voice.cancel(); dispatchShell({ type: "document-load-failed", offline: connectivity.displayed === "offline" }); incomingLinks.documentStarted(); shell.registry.invalidate(); shell.bridge.discardQueue(); + // A retry must not reuse the failed document's nonce. + setPublishedNonce(shell.registry.mintNonce()); }, [shell, connectivity.displayed, incomingLinks, voice]); + const legacyNoticed = useRef(""); + const announceIntake = useCallback(async () => { + try { + if (!(await intakePort.available())) return; + const items = await intakePort.list(); + if (shell.registry.handshake()?.protocolVersion === 4) { + shell.bridge.send("intake.changed", { pendingCount: items.length }); + } else if (items.length && visibilityRef.current === "active" && shell.registry.handshake()) { + const key = items.map((item) => item.id).join(","); + if (legacyNoticed.current === key) return; + legacyNoticed.current = key; + const failed = items.some((item) => item.kind === "error"); + const overflow = items.some( + (item) => item.kind === "error" && item.errorCode === "inbox-full", + ); + Alert.alert( + failed + ? locale === "de" + ? "Inhalt konnte nicht hinzugefügt werden" + : "Shared item could not be added" + : locale === "de" + ? "Geteilte Inhalte gespeichert" + : "Shared items saved", + overflow + ? locale === "de" + ? "Der Eingang ist voll. Die Webansicht aktualisieren und gespeicherte Inhalte öffnen oder verwerfen. Danach erneut teilen. Die Navigation läuft weiter." + : "The inbox is full. Update the web view, open or discard saved items, then share again. Navigation continues." + : failed + ? locale === "de" + ? "Einen Ort oder bis zu fünf GPX-, GeoJSON- oder KML-Dateien mit jeweils höchstens 5 MiB teilen. Die Webansicht für die Inhaltsprüfung aktualisieren. Die Navigation läuft weiter." + : "Share a location or up to five GPX, GeoJSON or KML files of at most 5 MiB each. Update the web view to review saved items. Navigation continues." + : locale === "de" + ? "Die Webansicht benötigt ein Update. Gespeicherte Inhalte bleiben bis zu 24 Stunden auf diesem Gerät. Die Navigation läuft weiter." + : "The web view needs an update. Saved items stay on this device for up to 24 hours. Navigation continues.", + ); + } + } catch { + /* Optional module absence cannot interrupt navigation. */ + } + }, [intakePort, shell, locale]); + useEffect( + () => + intakePort.listen(() => { + void announceIntake(); + }), + [intakePort, announceIntake], + ); + useEffect(() => { + if (visibility === "active") void announceIntake(); + }, [visibility, announceIntake]); const handleMessage = useCallback( (event: { nativeEvent: { url?: string; data?: string } }) => { // Rejections are silent by design: an error reply to an unauthenticated @@ -337,11 +437,12 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement shell.registry.handshake() ) { incomingLinks.bridgeReady(); + void announceIntake(); } }) .catch(() => undefined); }, - [shell, incomingLinks], + [shell, incomingLinks, announceIntake], ); const openExternally = useCallback((url: string) => { @@ -446,7 +547,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement { }); }); +describe("intake protocol compatibility", () => { + it("offers protocol 4 while retaining protocol 3 navigation", () => { + expect(MOBILE_PROTOCOL_MIN).toBe(3); + expect(MOBILE_PROTOCOL_MAX).toBe(4); + }); + + it("stamps pre-handshake navigation output with the selected older version", async () => { + const { bridge, registry, injected } = harness(); + const channel = registry.beginDocumentLoad(NOW); + expect(bridge.send("snapshot.update", { snapshot: { active: true } })).toBe("queued"); + expect(bridge.send("navigation.event", { eventId: "before-hello", event: {} })).toBe("queued"); + await bridge.receive({ url: WEB_ORIGIN, data: hello(channel.nonce, { min: 3, max: 3 }) }); + const messages = injected.map( + (s) => decodePayload(s) as unknown as { protocolVersion: number; type: string }, + ); + expect(messages.map((m) => m.type)).toEqual([ + "native.hello", + "snapshot.update", + "navigation.event", + ]); + expect(messages.map((m) => m.protocolVersion)).toEqual([3, 3, 3]); + }); + + it("omits new capabilities when an older web negotiates version 3", async () => { + const { bridge, registry, injected } = harness({ + describeShell: () => ({ + ...SHELL, + capabilities: { ...SHELL.capabilities, nativeIntake: true, carDestinations: true }, + }), + }); + const channel = registry.beginDocumentLoad(NOW); + await bridge.receive({ url: WEB_ORIGIN, data: hello(channel.nonce, { min: 3, max: 3 }) }); + expect(injected).toHaveLength(1); + const payload = decodePayload(injected[0]).payload as unknown as { + capabilities: Record; + }; + expect(payload.capabilities).not.toHaveProperty("nativeIntake"); + expect(payload.capabilities).not.toHaveProperty("carDestinations"); + }); +}); + +describe("intake protocol gates", () => { + it("drops version4-only queued events after a version3 negotiation", async () => { + const { bridge, registry, injected } = harness(); + const c = registry.beginDocumentLoad(NOW); + expect(bridge.send("intake.changed", { pendingCount: 1 })).toBe("queued"); + await bridge.receive({ url: WEB_ORIGIN, data: hello(c.nonce, { min: 3, max: 3 }) }); + expect(injected).toHaveLength(1); + expect(bridge.queuedCount).toBe(0); + }); + it("rejects intake commands on version3 before dispatch", async () => { + const { bridge, registry, dispatched } = harness(); + const c = registry.beginDocumentLoad(NOW); + await bridge.receive({ url: WEB_ORIGIN, data: hello(c.nonce, { min: 3, max: 3 }) }); + const result = await bridge.receive({ + url: WEB_ORIGIN, + data: command(c.nonce, { type: "intake.list", protocolVersion: 3 }), + }); + expect(result).toEqual({ status: "rejected", code: "unsupported-capability" }); + expect(dispatched).toEqual([]); + }); + it("advertises intake for a version4 peer", async () => { + const { bridge, registry, injected } = harness({ + describeShell: () => ({ + ...SHELL, + capabilities: { ...SHELL.capabilities, nativeIntake: true, carDestinations: true }, + }), + }); + const c = registry.beginDocumentLoad(NOW); + await bridge.receive({ url: WEB_ORIGIN, data: hello(c.nonce) }); + expect(decodePayload(injected[0]).payload).toMatchObject({ + capabilities: { nativeIntake: true, carDestinations: true }, + selectedProtocolVersion: 4, + }); + }); +}); + it("never queues or replays voice events before a document handshake", async () => { const f = harness(); f.registry.beginDocumentLoad(NOW); diff --git a/apps/mobile/src/bridge/nativeBridge.ts b/apps/mobile/src/bridge/nativeBridge.ts index b78cf8c7a..9f21e6670 100644 --- a/apps/mobile/src/bridge/nativeBridge.ts +++ b/apps/mobile/src/bridge/nativeBridge.ts @@ -31,7 +31,8 @@ export type BridgeRejectionCode = | "not-a-command" | "duplicate-message" | "handshake-required" - | "protocol-mismatch"; + | "protocol-mismatch" + | "unsupported-capability"; export type ReceiveOutcome = | { status: "handled"; type: WebToNativeMessage["type"] } @@ -54,6 +55,7 @@ export interface ShellDescription { backgroundLocation: boolean; localNotifications: boolean; speech: boolean; + nativeIntake?: boolean; carDestinations?: boolean; }; permission: "not-determined" | "foreground" | "background" | "denied" | "limited"; @@ -90,7 +92,7 @@ interface SendOptions { } export class NativeBridge { - private queue: NativeToWebMessage[] = []; + private queue: OutboundDraft[] = []; constructor(private readonly deps: NativeBridgeDeps) {} @@ -144,6 +146,15 @@ export class NativeBridge { return { status: "rejected", code: "protocol-mismatch" }; } + if (command.type.startsWith("intake.") && handshake.protocolVersion < 4) { + this.send( + "native.error", + { code: "unsupported-capability" }, + { forMessageId: command.messageId }, + ); + return { status: "rejected", code: "unsupported-capability" }; + } + try { await this.deps.dispatch(command); } catch { @@ -197,11 +208,20 @@ export class NativeBridge { protocolVersion: selected, }); } + // Pre-intake v3 clients use a strict capability schema. Neither new key + // may leak into their hello, including Android Auto's destination sync. + const { + nativeIntake: _intake, + carDestinations: _carDestinations, + ...legacyCapabilities + } = shell.capabilities; this.emit( { type: "native.hello", payload: { ...shell, + capabilities: + selected !== null && selected >= 4 ? shell.capabilities : legacyCapabilities, selectedProtocolVersion: selected, minProtocolVersion: MOBILE_PROTOCOL_MIN, maxProtocolVersion: MOBILE_PROTOCOL_MAX, @@ -235,6 +255,7 @@ export class NativeBridge { const handshake = registry.handshake(); const protocolVersion = options.protocolVersion ?? handshake?.protocolVersion ?? MOBILE_PROTOCOL_MAX; + if (handshake && protocolVersion < 4 && draft.type.startsWith("intake.")) return "dropped"; const correlatedPayload = forMessageId && draft.payload !== null && @@ -261,7 +282,13 @@ export class NativeBridge { if (!validated.success) return "invalid"; if (draft.type === "voice.event" && !handshake) return "dropped"; - if (!options.immediate && !handshake) return this.enqueue(validated.data); + if (!options.immediate && !handshake) + return this.enqueue({ + type: validated.data.type, + payload: validated.data.payload, + ...(draft.sessionId === undefined ? {} : { sessionId: draft.sessionId }), + ...(draft.revision === undefined ? {} : { revision: draft.revision }), + }); this.deps.inject(buildOutboundScript(validated.data)); return "sent"; @@ -277,7 +304,7 @@ export class NativeBridge { * queue of nothing but critical messages refuses new work rather than * discarding any of it. */ - private enqueue(message: NativeToWebMessage): SendOutcome { + private enqueue(message: OutboundDraft): SendOutcome { if (message.type === "snapshot.update") { this.queue = this.queue.filter((entry) => entry.type !== "snapshot.update"); } @@ -292,10 +319,15 @@ export class NativeBridge { /** Delivers everything held while the page was not ready. */ flush(): number { + const handshake = this.deps.registry.handshake(); + if (!handshake) return 0; const pending = this.queue; this.queue = []; - for (const message of pending) this.deps.inject(buildOutboundScript(message)); - return pending.length; + let sent = 0; + for (const draft of pending) { + if (this.emit(draft, { protocolVersion: handshake.protocolVersion }) === "sent") sent++; + } + return sent; } /** Drops queued output, e.g. when the document that would receive it is gone. */ diff --git a/apps/mobile/src/intake/IntakeController.test.ts b/apps/mobile/src/intake/IntakeController.test.ts new file mode 100644 index 000000000..9e5601aca --- /dev/null +++ b/apps/mobile/src/intake/IntakeController.test.ts @@ -0,0 +1,95 @@ +import { IntakeController } from "./IntakeController"; +import type { NativeIntakePort } from "./nativeIntakeModule"; + +const id = "f386e706-b728-40a3-9554-881d0ff0958a"; +function fixture() { + let nonce = "first"; + let release: (() => void) | undefined; + const port = { + available: async () => true, + list: jest.fn(async () => []), + read: jest.fn(), + complete: jest.fn(), + listen: () => () => undefined, + } as NativeIntakePort; + const send = jest.fn(); + const canApply = jest.fn(async () => true); + const controller = new IntakeController({ + port, + currentNonce: () => nonce, + send, + canApply, + scheme: "openmapx", + }); + return { + controller, + canApply, + port, + send, + reset: () => { + nonce = "second"; + }, + delay: () => { + port.list = () => + new Promise((r) => { + release = () => r([]); + }); + }, + release: () => release?.(), + }; +} +const message = (type: string, payload = {}) => ({ + type, + payload, + messageId: "request1", + channelNonce: "first", + protocolVersion: 4, + sentAtMs: 0, +}); +it("correlates an empty list without claiming apply authority", async () => { + const f = fixture(); + await f.controller.dispatch(message("intake.list") as never); + expect(f.send).toHaveBeenCalledWith("intake.items", { + items: [], + canApply: false, + scheme: "openmapx", + forMessageId: "request1", + }); +}); +it("drops replies after a document reload", async () => { + const f = fixture(); + f.delay(); + const pending = f.controller.dispatch(message("intake.list") as never); + f.reset(); + f.release(); + await pending; + expect(f.send).not.toHaveBeenCalled(); +}); +it("does not delete read items, and completes only explicitly", async () => { + const f = fixture(); + await f.controller.dispatch(message("intake.read", { id, offset: 0 }) as never); + expect(f.port.complete).not.toHaveBeenCalled(); + await f.controller.dispatch( + message("intake.complete", { id, disposition: "discarded" }) as never, + ); + expect(f.port.complete).toHaveBeenCalledWith(id); +}); +it("never dispatches a command from an obsolete document", async () => { + const f = fixture(); + f.reset(); + await f.controller.dispatch( + message("intake.complete", { id, disposition: "discarded" }) as never, + ); + expect(f.port.complete).not.toHaveBeenCalled(); +}); + +it("does not query unavailable navigation authority for an empty inbox", async () => { + const f = fixture(); + f.canApply.mockRejectedValue(new Error("startup failed")); + await f.controller.dispatch(message("intake.list") as never); + expect(f.canApply).not.toHaveBeenCalled(); + expect(f.send).toHaveBeenCalledWith( + "intake.items", + expect.objectContaining({ items: [], canApply: false }), + ); +}); diff --git a/apps/mobile/src/intake/IntakeController.ts b/apps/mobile/src/intake/IntakeController.ts new file mode 100644 index 000000000..8f2374959 --- /dev/null +++ b/apps/mobile/src/intake/IntakeController.ts @@ -0,0 +1,49 @@ +import type { NativeToWebMessage, WebToNativeMessage } from "@openmapx/core/navigation"; +import { NativeIntakeError, type NativeIntakePort } from "./nativeIntakeModule"; + +interface Dependencies { + port: NativeIntakePort; + currentNonce(): string | undefined; + send(type: NativeToWebMessage["type"], payload: unknown): unknown; + canApply(): Promise; + scheme: string; +} +/** The bridge has already authenticated commands. Every async reply is fenced to its document. */ +export class IntakeController { + constructor(private readonly deps: Dependencies) {} + async dispatch(message: WebToNativeMessage): Promise { + const current = () => this.deps.currentNonce() === message.channelNonce; + if (!current() || message.protocolVersion < 4) return; + const reply = (type: NativeToWebMessage["type"], payload: Record) => { + if (current()) this.deps.send(type, { ...payload, forMessageId: message.messageId }); + }; + try { + switch (message.type) { + case "intake.list": { + const items = await this.deps.port.list(); + const canApply = items.length > 0 ? await this.deps.canApply() : false; + reply("intake.items", { items, canApply, scheme: this.deps.scheme }); + break; + } + case "intake.read": + reply("intake.chunk", { + ...(await this.deps.port.read(message.payload.id, message.payload.offset)), + }); + break; + case "intake.complete": + if (message.payload.disposition === "opened" && !(await this.deps.canApply())) { + reply("native.error", { code: "navigation-active" }); + break; + } + if (!current()) return; + await this.deps.port.complete(message.payload.id); + reply("intake.completed", { id: message.payload.id }); + break; + } + } catch (error) { + reply("native.error", { + code: error instanceof NativeIntakeError ? error.code : "unavailable", + }); + } + } +} diff --git a/apps/mobile/src/intake/nativeIntakeModule.test.ts b/apps/mobile/src/intake/nativeIntakeModule.test.ts new file mode 100644 index 000000000..d3232f8ab --- /dev/null +++ b/apps/mobile/src/intake/nativeIntakeModule.test.ts @@ -0,0 +1,51 @@ +import { requireOptionalNativeModule } from "expo"; +import { createNativeIntakePort } from "./nativeIntakeModule"; + +jest.mock("expo", () => ({ requireOptionalNativeModule: jest.fn() })); +const load = jest.mocked(requireOptionalNativeModule); +const id = "12345678-1234-4234-8234-123456789abc"; +afterEach(() => jest.resetAllMocks()); +it("does not crash an older binary without the module", async () => { + load.mockReturnValue(null); + const port = createNativeIntakePort(); + expect(await port.available()).toBe(false); + await expect(port.list()).rejects.toMatchObject({ code: "unavailable" }); +}); +it("refuses native paths and malformed metadata at the module boundary", async () => { + load.mockReturnValue({ + listAsync: async () => ({ + items: [ + { + id, + name: "route.gpx", + kind: "file", + format: "gpx", + byteLength: 10, + createdAtMs: 1, + path: "/private/secret", + }, + ], + }), + } as never); + await expect(createNativeIntakePort().list()).rejects.toMatchObject({ code: "invalid-data" }); +}); +it("retains a closed native error code without its native exception text", async () => { + load.mockReturnValue({ listAsync: async () => ({ errorCode: "inbox-full" }) } as never); + await expect(createNativeIntakePort().list()).rejects.toMatchObject({ + code: "inbox-full", + message: "inbox-full", + }); +}); +it("validates chunk arguments before calling native and forwards only the opaque id", async () => { + const readChunkAsync = jest + .fn() + .mockResolvedValue({ id, offset: 0, data: "YQ==", byteLength: 1, eof: true }); + const completeAsync = jest.fn().mockResolvedValue({ ok: true }); + load.mockReturnValue({ readChunkAsync, completeAsync } as never); + const port = createNativeIntakePort(); + await expect(port.read("../secret", 0)).rejects.toMatchObject({ code: "invalid-data" }); + expect(readChunkAsync).not.toHaveBeenCalled(); + expect((await port.read(id, 0)).data).toBe("YQ=="); + await port.complete(id); + expect(completeAsync).toHaveBeenCalledWith(id); +}); diff --git a/apps/mobile/src/intake/nativeIntakeModule.ts b/apps/mobile/src/intake/nativeIntakeModule.ts new file mode 100644 index 000000000..41b1ff34d --- /dev/null +++ b/apps/mobile/src/intake/nativeIntakeModule.ts @@ -0,0 +1,113 @@ +import { + INTAKE_CHUNK_BYTES, + INTAKE_ERROR_CODES, + type IntakeChunk, + type IntakeErrorCode, + type IntakeItem, + intakeChunkSchema, + intakeIdSchema, + intakeItemSchema, + MAX_INTAKE_FILE_BYTES, +} from "@openmapx/core/navigation"; +import { requireOptionalNativeModule } from "expo"; +import { z } from "zod"; + +interface NativeIntake { + isAvailableAsync(): Promise; + listAsync(): Promise; + readChunkAsync(id: string, offset: number): Promise; + completeAsync(id: string): Promise; + addListener(name: "onInboxChanged", listener: () => void): { remove(): void }; +} +export class NativeIntakeError extends Error { + constructor(readonly code: IntakeErrorCode | "navigation-unavailable") { + super(code); + } +} +export interface NativeIntakePort { + available(): Promise; + list(): Promise; + read(id: string, offset: number): Promise; + complete(id: string): Promise; + listen(listener: () => void): () => void; +} +export function createNativeIntakePort(): NativeIntakePort { + let native: NativeIntake | null; + try { + native = requireOptionalNativeModule("OpenMapXIntake"); + } catch { + native = null; + } + async function invoke(work: (module: NativeIntake) => Promise): Promise { + if (!native) throw new NativeIntakeError("unavailable"); + let result: unknown; + try { + result = await work(native); + } catch { + throw new NativeIntakeError("unavailable"); + } + if (result && typeof result === "object" && "errorCode" in result) { + const code = (result as { errorCode?: unknown }).errorCode; + throw new NativeIntakeError( + typeof code === "string" && (INTAKE_ERROR_CODES as readonly string[]).includes(code) + ? (code as IntakeErrorCode) + : "unavailable", + ); + } + return result; + } + function validId(id: string): void { + if (!intakeIdSchema.safeParse(id).success) throw new NativeIntakeError("invalid-data"); + } + return { + async available() { + try { + return Boolean(await native?.isAvailableAsync()); + } catch { + return false; + } + }, + async list() { + const parsed = z + .object({ items: z.array(intakeItemSchema).max(10) }) + .strict() + .safeParse(await invoke((m) => m.listAsync())); + if (!parsed.success) throw new NativeIntakeError("invalid-data"); + return parsed.data.items; + }, + async read(id, offset) { + validId(id); + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + offset > MAX_INTAKE_FILE_BYTES || + offset % INTAKE_CHUNK_BYTES !== 0 + ) + throw new NativeIntakeError("invalid-data"); + const parsed = intakeChunkSchema.safeParse(await invoke((m) => m.readChunkAsync(id, offset))); + if (!parsed.success || parsed.data.id !== id || parsed.data.offset !== offset) + throw new NativeIntakeError("invalid-data"); + return parsed.data; + }, + async complete(id) { + validId(id); + const result = await invoke((m) => m.completeAsync(id)); + if ( + !z + .object({ ok: z.literal(true) }) + .strict() + .safeParse(result).success + ) + throw new NativeIntakeError("invalid-data"); + }, + listen(listener) { + if (!native) return () => undefined; + try { + const subscription = native.addListener("onInboxChanged", listener); + return () => subscription.remove(); + } catch { + return () => undefined; + } + }, + }; +} diff --git a/apps/mobile/src/links/deepLinkIntent.test.ts b/apps/mobile/src/links/deepLinkIntent.test.ts index 0e6ffb534..aa7d90a6e 100644 --- a/apps/mobile/src/links/deepLinkIntent.test.ts +++ b/apps/mobile/src/links/deepLinkIntent.test.ts @@ -46,6 +46,8 @@ describe("parseDeepLinkIntent refuses", () => { { label: "plain HTTP on the right host", link: "http://openmapx.com/?q=cafe" }, { label: "an unknown scheme", link: "openmapx-evil://?q=cafe" }, { label: "a javascript URL", link: "javascript:alert(1)" }, + { label: "an iOS document URL", link: "file:///private/var/mobile/Documents/Inbox/route.gpx" }, + { label: "an Android content URI", link: "content://provider/route.gpx" }, { label: "a data URL", link: "data:text/html," }, { label: "not a URL at all", link: "just some text" }, { label: "an empty string", link: "" }, diff --git a/apps/mobile/src/navigation/NavigationCoordinator.test.ts b/apps/mobile/src/navigation/NavigationCoordinator.test.ts index 9d61de713..e8a084430 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.test.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.test.ts @@ -1001,6 +1001,28 @@ describe("native surface stop", () => { }); }); +it("refuses intake routed to an uncomposed navigation coordinator without changing a trip", async () => { + const h = await harness(); + try { + const response = await h.coordinator.dispatch({ + protocolVersion: MOBILE_PROTOCOL_MAX, + type: "intake.list", + messageId: "intake-uncomposed", + channelNonce: "nonce", + sentAtMs: NOW, + payload: {}, + }); + expect(response).toMatchObject({ + type: "native.error", + payload: { code: "unsupported-capability" }, + }); + expect(await h.repository.loadActive(NOW)).toBeNull(); + expect(h.effectLog).toEqual([]); + } finally { + await h.close(); + } +}); + describe("car starts share the coordinator authority", () => { const context = () => ({ kind: "car", hostAuthorized: true, locationServiceStartAllowed: true }) as const; diff --git a/apps/mobile/src/navigation/NavigationCoordinator.ts b/apps/mobile/src/navigation/NavigationCoordinator.ts index 2051001d6..57d2ee2ca 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.ts @@ -261,6 +261,11 @@ export class NavigationCoordinator { case "web.hello": // Answered by the bridge during the handshake; nothing to coordinate. return null; + case "intake.list": + case "intake.read": + case "intake.complete": + // Intake is composed by the visible shell, never the headless navigator. + return errorResponse("unsupported-capability", command.messageId); case "session.prepare": return this.prepare(command, now); case "session.start": diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9c0898bc4..ebad5857e 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter"; import type { Metadata, Viewport } from "next"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; +import { NativeIntakeHandler } from "@/components/pwa/NativeIntakeHandler"; import "@photo-sphere-viewer/core/index.css"; import "@photo-sphere-viewer/virtual-tour-plugin/index.css"; import "maplibre-theme/icons.default.css"; @@ -87,6 +88,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac + diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx new file mode 100644 index 000000000..f33b1f0cf --- /dev/null +++ b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx @@ -0,0 +1,247 @@ +import { useImportedGeometryStore, useNavigationStore } from "@openmapx/core"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { IntakeTransferError } from "@/lib/mobile/nativeIntake"; +import { NativeIntakeHandler } from "./NativeIntakeHandler"; + +const f = vi.hoisted(() => ({ + list: vi.fn(), + text: vi.fn(), + complete: vi.fn(), + mutating: false, + pathname: "/", + push: vi.fn(), +})); +const item = { + id: "f386e706-b728-40a3-9554-881d0ff0958a", + kind: "file", + format: "geojson", + name: "walk.geojson", + byteLength: 50, + createdAtMs: 1, +}; +vi.mock("next/navigation", () => ({ + usePathname: () => f.pathname, + useRouter: () => ({ push: f.push }), +})); +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })); +vi.mock("@/lib/mobile/nativeIntake", () => ({ + IntakeTransferError: class extends Error { + constructor(readonly code: string) { + super(code); + } + }, + NativeIntakeClient: class { + list = f.list; + text = f.text; + complete = f.complete; + }, +})); +const listeners = new Set<(message: never) => void>(); +const client = { + subscribe: (listener: (message: never) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; +const commands = { + get isMutating() { + return f.mutating; + }, +}; +vi.mock("@/lib/mobile/MobileRuntimeProvider", () => ({ + useMobileRuntimeContext: () => ({ + state: "native-compatible", + handshake: { selectedProtocolVersion: 4, capabilities: { nativeIntake: true } }, + client, + commands, + }), +})); +beforeEach(() => { + vi.clearAllMocks(); + f.mutating = false; + f.pathname = "/"; + useNavigationStore.setState({ status: "idle", nativeSessionId: null }); + useImportedGeometryStore.getState().clearImported(); + f.list.mockResolvedValue({ items: [item], canApply: true, scheme: "openmapx" }); + f.text.mockResolvedValue('{"type":"Point","coordinates":[8,50]}'); + f.complete.mockResolvedValue(undefined); +}); +it("previews without applying, Later retains content", async () => { + render(); + await screen.findByText("features"); + expect(useImportedGeometryStore.getState().imported).toBeNull(); + fireEvent.click(screen.getByText("later")); + expect(f.complete).not.toHaveBeenCalled(); +}); +it("imports only on confirmation and acknowledges the exact item", async () => { + render(); + await screen.findByText("features"); + f.list.mockResolvedValue({ items: [item], canApply: true, scheme: "openmapx" }); + fireEvent.click(screen.getByText("import")); + await waitFor(() => expect(f.complete).toHaveBeenCalledWith(item.id, "opened")); + expect(useImportedGeometryStore.getState().imported?.name).toBe("walk.geojson"); +}); +it("defers a navigation-start race without applying or deleting", async () => { + render(); + await screen.findByText("features"); + f.list.mockImplementation(async () => { + f.mutating = true; + return { items: [item], canApply: true, scheme: "openmapx" }; + }); + fireEvent.click(screen.getByText("import")); + await screen.findByText("deferred"); + expect(useImportedGeometryStore.getState().imported).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); +}); +it("does not auto-open or consume while native reports an active trip", async () => { + f.list.mockResolvedValue({ items: [item], canApply: false, scheme: "openmapx" }); + render(); + await screen.findByText("pending"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(f.text).not.toHaveBeenCalled(); + expect(f.complete).not.toHaveBeenCalled(); +}); + +it("offers a route back to the map before previewing content on settings pages", async () => { + f.pathname = "/settings"; + render(); + await screen.findByText("pending"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(f.text).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("pending")); + expect(f.push).toHaveBeenCalledWith("/"); + expect(f.complete).not.toHaveBeenCalled(); +}); + +it("keeps an edited text draft across a fresh listing of the same immutable item", async () => { + const textItem = { + id: item.id, + kind: "text", + name: "Shared location", + createdAtMs: 1, + byteLength: 6, + }; + f.list.mockImplementation(async () => ({ + items: [{ ...textItem }], + canApply: true, + scheme: "openmapx", + })); + f.text.mockResolvedValue("Berlin"); + render(); + const field = await screen.findByLabelText("search"); + await waitFor(() => expect(field).toHaveValue("Berlin")); + fireEvent.change(field, { target: { value: "Edited destination" } }); + const before = f.list.mock.calls.length; + await act(async () => { + for (const listener of listeners) listener({ type: "intake.changed" } as never); + await Promise.resolve(); + }); + await waitFor(() => expect(f.list.mock.calls.length).toBeGreaterThan(before)); + expect(field).toHaveValue("Edited destination"); + expect(f.text).toHaveBeenCalledTimes(1); +}); +it("does not restart a file read on a metadata refresh", async () => { + f.list.mockImplementation(async () => ({ + items: [{ ...item }], + canApply: true, + scheme: "openmapx", + })); + render(); + await screen.findByText("features"); + const before = f.list.mock.calls.length; + await act(async () => { + for (const listener of listeners) listener({ type: "intake.changed" } as never); + await Promise.resolve(); + }); + await waitFor(() => expect(f.list.mock.calls.length).toBeGreaterThan(before)); + expect(f.text).toHaveBeenCalledTimes(1); +}); + +it("offers postponed items again on a later foreground", async () => { + render(); + await screen.findByText("features"); + fireEvent.click(screen.getByText("later")); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + const previous = Object.getOwnPropertyDescriptor(document, "visibilityState"); + try { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await Promise.resolve(); + }); + await screen.findByRole("dialog"); + expect(f.complete).not.toHaveBeenCalled(); + } finally { + if (previous) Object.defineProperty(document, "visibilityState", previous); + else Reflect.deleteProperty(document, "visibilityState"); + } +}); + +it("reports unavailable authority instead of inventing an active trip, and can recheck", async () => { + f.list.mockRejectedValue(new IntakeTransferError("navigation-unavailable")); + render(); + await screen.findByText("navigationUnavailable"); + expect(screen.queryByText("deferred")).toBeNull(); + expect(screen.queryByText("import")).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); + f.list.mockResolvedValue({ items: [item], canApply: true, scheme: "openmapx" }); + fireEvent.click(screen.getByText("retry")); + await screen.findByText("features"); + expect(screen.queryByText("navigationUnavailable")).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); +}); +it("keeps a previous preview blocked when an authority refresh fails", async () => { + render(); + await screen.findByText("features"); + f.list.mockRejectedValue(new IntakeTransferError("navigation-unavailable")); + fireEvent.click(screen.getByText("import")); + await screen.findByText("navigationUnavailable"); + expect(useImportedGeometryStore.getState().imported).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); +}); + +it("keeps a transient listing failure silent when no shared items are known", async () => { + f.list.mockRejectedValue(new Error("temporary bridge timeout")); + render(); + await waitFor(() => expect(f.list).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); +}); +it("shows a generic listing failure when shared items are known", async () => { + render(); + await screen.findByText("features"); + f.list.mockRejectedValue(new Error("temporary bridge timeout")); + await act(async () => { + for (const listener of listeners) listener({ type: "intake.changed" } as never); + }); + await screen.findByText("listingUnavailable"); + expect(f.complete).not.toHaveBeenCalled(); +}); + +it("shows no notice for an empty inbox without apply authority", async () => { + f.list.mockResolvedValue({ items: [], canApply: false, scheme: "openmapx" }); + render(); + await waitFor(() => expect(f.list).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText("pending")).toBeNull(); +}); + +it("reports an item gone from the empty inbox as expired rather than an active trip", async () => { + render(); + await screen.findByText("features"); + f.list.mockResolvedValue({ items: [], canApply: false, scheme: "openmapx" }); + fireEvent.click(screen.getByText("import")); + await screen.findByText("expired"); + expect(screen.queryByText("deferred")).toBeNull(); + expect(useImportedGeometryStore.getState().imported).toBeNull(); + expect(f.complete).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.tsx new file mode 100644 index 000000000..a0c78dfb9 --- /dev/null +++ b/apps/web/src/components/pwa/NativeIntakeHandler.tsx @@ -0,0 +1,395 @@ +"use client"; +import { + Alert, + Button, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useImportedGeometryStore, useNavigationStore } from "@openmapx/core"; +import { classifySharedText, type IntakeItem } from "@openmapx/core/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; +import { DEEPLINK_OPEN_EVENT } from "@/lib/deepLink"; +import { parseGeoFile } from "@/lib/importGeoFile"; +import { useMobileRuntimeContext } from "@/lib/mobile/MobileRuntimeProvider"; +import { applyNativeDeepLink } from "@/lib/mobile/nativeDeepLink"; +import { IntakeTransferError, NativeIntakeClient } from "@/lib/mobile/nativeIntake"; + +/** User confirmation is the only path from owned inbox bytes to map state. */ +export function NativeIntakeHandler() { + const t = useTranslations("pwa.intake"); + const runtime = useMobileRuntimeContext(); + const pathname = usePathname(); + const router = useRouter(); + const pathRef = useRef(pathname); + pathRef.current = pathname; + const enabled = + runtime.state === "native-compatible" && + runtime.handshake?.selectedProtocolVersion === 4 && + runtime.handshake.capabilities.nativeIntake === true; + const client = useMemo( + () => (enabled && runtime.client ? new NativeIntakeClient(runtime.client) : null), + [enabled, runtime.client], + ); + const navStatus = useNavigationStore((s) => s.status); + const nativeSessionId = useNavigationStore((s) => s.nativeSessionId); + const imported = useImportedGeometryStore((s) => s.imported); + const localBlocked = useCallback( + () => + pathRef.current !== "/" || + runtime.commands?.isMutating === true || + useNavigationStore.getState().nativeSessionId !== null || + !["idle", "planning", "preview"].includes(useNavigationStore.getState().status), + [runtime.commands], + ); + const [items, setItems] = useState([]), + [selected, setSelected] = useState(""); + const [visible, setVisible] = useState(false), + [blocked, setBlocked] = useState(true); + const [listingUnavailable, setListingUnavailable] = useState(""); + const [busy, setBusy] = useState(false), + [loading, setLoading] = useState(false), + [error, setError] = useState(""); + const [draft, setDraft] = useState(""), + [scheme, setScheme] = useState("openmapx"); + const [geometry, setGeometry] = useState(null); + const visibleRef = useRef(visible); + visibleRef.current = visible; + const later = useRef(new Set()), + generation = useRef(0), + refreshVersion = useRef(0); + const selectedItem = items.find((item) => item.id === selected) ?? items[0]; + const selectedId = selectedItem?.id; + const selectedKind = selectedItem?.kind; + const selectedItemRef = useRef(selectedItem); + selectedItemRef.current = selectedItem; + const refresh = useCallback(async () => { + if (!client) return; + const version = ++refreshVersion.current; + const current = generation.current; + try { + const result = await client.list(); + if (generation.current !== current || version !== refreshVersion.current) return; + setListingUnavailable(""); + setItems(result.items); + setScheme(result.scheme); + setBlocked(!result.canApply); + if ( + result.canApply && + !localBlocked() && + result.items.some((item) => !later.current.has(item.id)) && + !visibleRef.current + ) { + setSelected(result.items.find((item) => !later.current.has(item.id))?.id ?? ""); + setVisible(true); + } + } catch (error) { + if (generation.current !== current || version !== refreshVersion.current) return; + setBlocked(true); + setListingUnavailable( + error instanceof IntakeTransferError && error.code === "navigation-unavailable" + ? error.code + : selectedItemRef.current + ? "unavailable" + : "", + ); + // Owned items and edited drafts survive a failed authority check. + } + }, [client, localBlocked]); + useEffect(() => { + if (pathname !== "/") setVisible(false); + generation.current++; + void refresh(); + const stop = runtime.client?.subscribe((message) => { + if (message.type === "intake.changed" || message.type === "session.stopped") void refresh(); + }); + const foreground = () => { + if (document.visibilityState !== "hidden") { + later.current.clear(); + void refresh(); + } + }; + document.addEventListener("visibilitychange", foreground); + return () => { + generation.current++; + stop?.(); + document.removeEventListener("visibilitychange", foreground); + }; + }, [refresh, runtime.client, pathname]); + useEffect(() => { + if (navStatus === "idle" && nativeSessionId === null) void refresh(); + }, [navStatus, nativeSessionId, refresh]); + useEffect(() => { + const selectedItem = selectedItemRef.current; + if ( + !visible || + !selectedItem || + selectedItem.id !== selectedId || + selectedItem.kind !== selectedKind || + !client + ) + return; + const abort = new AbortController(); + setDraft(""); + setGeometry(null); + setError(""); + if (selectedItem.kind === "error") { + setLoading(false); + return; + } + setLoading(true); + void client + .text(selectedItem, abort.signal) + .then((text) => { + if (abort.signal.aborted) return; + if (selectedItem.kind === "text") setDraft(text); + else { + const parsed = parseGeoFile(selectedItem.name, text); + if (!parsed) setError("invalid-data"); + setGeometry(parsed); + } + }) + .catch((error: unknown) => { + if (!abort.signal.aborted) + setError(error instanceof IntakeTransferError ? error.code : "unreadable"); + }) + .finally(() => { + if (!abort.signal.aborted) setLoading(false); + }); + return () => abort.abort(); + }, [visible, selectedId, selectedKind, client]); + function postpone() { + for (const item of items) later.current.add(item.id); + setVisible(false); + } + async function discard() { + if (!client || !selectedItem || busy) return; + setBusy(true); + try { + await client.complete(selectedItem.id, "discarded"); + setItems((items) => items.filter((item) => item.id !== selectedItem.id)); + await refresh(); + } catch (error) { + if (error instanceof IntakeTransferError && error.code === "navigation-active") + setBlocked(true); + else if (error instanceof IntakeTransferError && error.code === "navigation-unavailable") { + setBlocked(true); + setListingUnavailable(error.code); + } else setError(error instanceof IntakeTransferError ? error.code : "unavailable"); + } finally { + setBusy(false); + } + } + async function apply() { + if (!client || !selectedItem || busy) return; + setBusy(true); + setError(""); + const documentGeneration = generation.current; + try { + const current = await client.list(); + if (generation.current !== documentGeneration) return; + if (!current.items.some((item) => item.id === selectedItem.id)) { + setError("expired"); + return; + } + if (!current.canApply || localBlocked()) { + setBlocked(true); + return; + } + // No await between the final local authority check and synchronous map application. + if (selectedItem.kind === "file" && geometry) + useImportedGeometryStore + .getState() + .setImported({ name: selectedItem.name, geojson: geometry }); + else if (selectedItem.kind === "text") { + const intent = classifySharedText(draft, { + webOrigin: window.location.origin, + scheme: current.scheme, + }); + if (intent.kind !== "map") { + setError("unsupported"); + return; + } + applyNativeDeepLink(intent, { + replaceSearch: (query) => { + const url = new URL(window.location.href); + url.search = query; + window.history.replaceState(window.history.state, "", url.toString()); + }, + notify: () => window.dispatchEvent(new Event(DEEPLINK_OPEN_EVENT)), + showActiveNavigation: () => undefined, + requestSnapshot: () => undefined, + }); + } else return; + // If acknowledgement fails, leave the item available for an explicit retry. + await client.complete(selectedItem.id, "opened"); + later.current.add(selectedItem.id); + setVisible(false); + await refresh(); + } catch (error) { + if (error instanceof IntakeTransferError && error.code === "navigation-active") + setBlocked(true); + else if (error instanceof IntakeTransferError && error.code === "navigation-unavailable") { + setBlocked(true); + setListingUnavailable(error.code); + } else setError(error instanceof IntakeTransferError ? error.code : "unavailable"); + } finally { + setBusy(false); + } + } + if (!client) return null; + if (listingUnavailable) + return ( + void refresh()}>{t("retry")}} + > + {t( + listingUnavailable === "navigation-unavailable" + ? "navigationUnavailable" + : "listingUnavailable", + )} + + ); + if (!items.length) return null; + const waiting = blocked || localBlocked(); + const invalidText = + selectedItem?.kind === "text" && + classifySharedText(draft, { webOrigin: window.location.origin, scheme }).kind !== "map"; + const code = selectedItem?.kind === "error" ? selectedItem.errorCode : error; + return ( + <> + + + {t("title")} + + + {items.length > 1 && ( + setSelected(event.target.value)} + disabled={busy} + > + {items.map((item) => ( + + {item.kind === "text" ? t("sharedLocation") : item.name} + + ))} + + )} + + {selectedItem?.kind === "text" ? t("sharedLocation") : selectedItem?.name} + + {waiting && {t("deferred")}} + {items.some((item) => item.kind === "error") && selectedItem?.kind !== "error" && ( + {t("partial")} + )} + {loading && {t("loading")}} + {code && ( + + {t( + (( + { + "too-large": "tooLarge", + "too-many": "tooMany", + "inbox-full": "inboxFull", + expired: "expired", + unsupported: "unsupported", + } as Record + )[code] ?? "failed") as "failed", + )} + + )} + {selectedItem?.kind === "text" && !loading && ( + setDraft(event.target.value)} + multiline + slotProps={{ htmlInput: { maxLength: 4096 } }} + helperText={invalidText ? t("editUnsupported") : t("textHelp")} + /> + )} + {geometry && ( + + {t("features", { + count: geometry.features.length, + format: + selectedItem?.kind === "file" + ? { gpx: "GPX", geojson: "GeoJSON", kml: "KML" }[selectedItem.format] + : "", + })} + + )} + {geometry && imported && ( + {t("replace", { name: imported.name })} + )} + + {t("retention")} + + + + + + + {selectedItem?.kind !== "error" && ( + + )} + + + + ); +} diff --git a/apps/web/src/lib/importGeoFile.ts b/apps/web/src/lib/importGeoFile.ts index 99b0d05d2..8272a6f90 100644 --- a/apps/web/src/lib/importGeoFile.ts +++ b/apps/web/src/lib/importGeoFile.ts @@ -7,6 +7,11 @@ import { } from "@openmapx/core/saved-place-contributors"; import { withFeatureContributors } from "@openmapx/mobility-core/contributors"; import { gpx, kml } from "@tmcw/togeojson"; +import { + boundedImportText, + MAX_IMPORT_BYTES, + validImportedCollection, +} from "./validateImportedGeometry"; /** Extensions the importer (and the manifest file_handlers) accept. */ export const IMPORT_ACCEPT = ".gpx,.geojson,.json,.kml"; @@ -37,6 +42,9 @@ function importedFeature(feature: GeoJSON.Feature): GeoJSON.Feature { const { _mapAttributions: _untrusted, fieldContributors: raw, + description: _description, + icon: _icon, + styleUrl: _styleUrl, ...properties } = feature.properties ?? {}; const captured = parseSavedPlaceContributors(raw); @@ -55,15 +63,17 @@ function importedFeature(feature: GeoJSON.Feature): GeoJSON.Feature { * for unsupported extensions, malformed XML/JSON, or empty results. */ export function parseGeoFile(filename: string, text: string): GeoJSON.FeatureCollection | null { + if (!boundedImportText(text)) return null; const lower = filename.toLowerCase(); try { if (lower.endsWith(".geojson") || lower.endsWith(".json")) { const fc = toFeatureCollection(JSON.parse(text)); - return fc && fc.features.length > 0 + return fc && validImportedCollection(fc) ? { ...fc, features: fc.features.map(importedFeature) } : null; } if (lower.endsWith(".gpx") || lower.endsWith(".kml")) { + if (/ 0 + return validImportedCollection({ type: "FeatureCollection", features }) ? { type: "FeatureCollection", features: features.map(importedFeature) } : null; } @@ -115,6 +125,7 @@ export function parseGeoFile(filename: string, text: string): GeoJSON.FeatureCol */ export async function importGeoFromFile(file: File): Promise { try { + if (file.size > MAX_IMPORT_BYTES) return false; const text = await file.text(); const geojson = parseGeoFile(file.name, text); if (!geojson) return false; diff --git a/apps/web/src/lib/mobile/bridgeClient.test.ts b/apps/web/src/lib/mobile/bridgeClient.test.ts index 0f9cbee44..bcfa7df84 100644 --- a/apps/web/src/lib/mobile/bridgeClient.test.ts +++ b/apps/web/src/lib/mobile/bridgeClient.test.ts @@ -1,5 +1,5 @@ import { MOBILE_PROTOCOL_MAX } from "@openmapx/core/navigation"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BridgeClient, BridgeError, @@ -581,3 +581,71 @@ describe("BridgeError", () => { expect(error.name).toBe("BridgeError"); }); }); + +describe("older native protocol negotiation", () => { + it("accepts version 3 and uses it for subsequent navigation requests", async () => { + const context = harness(); + context.client.attach(); + const promise = context.client.hello(); + const sent = JSON.parse(context.sent[0]); + const reply = helloReply(sent.messageId, { + selectedProtocolVersion: 3, + minProtocolVersion: 3, + maxProtocolVersion: 3, + }); + reply.protocolVersion = 3; + context.deliver(reply); + await expect(promise).resolves.toMatchObject({ selectedProtocolVersion: 3 }); + const pending = context.client.request("snapshot.request", {}); + expect(JSON.parse(context.sent[1]).protocolVersion).toBe(3); + context.client.close(); + await expect(pending).rejects.toBeInstanceOf(BridgeError); + }); +}); + +it("accepts a null selection using the incompatible native's declared envelope version", async () => { + const context = harness(); + context.client.attach(); + const pending = context.client.hello(); + const sent = JSON.parse(context.sent[0]); + const reply = helloReply(sent.messageId, { + selectedProtocolVersion: null, + minProtocolVersion: 2, + maxProtocolVersion: 2, + }); + reply.protocolVersion = 2; + context.deliver(reply); + await expect(pending).resolves.toMatchObject({ selectedProtocolVersion: null }); + context.client.close(); +}); + +it("uses real browser timer defaults for deadlines and cancellation", async () => { + vi.useFakeTimers(); + const schedule = vi.spyOn(globalThis, "setTimeout"); + const cancel = vi.spyOn(globalThis, "clearTimeout"); + const shell = shellScope(); + const client = new BridgeClient({ webBuildId: "production-timers", scope: shell.scope }); + try { + client.attach(); + const ready = client.hello(); + const hello = JSON.parse(shell.sent[0]); + shell.deliver(helloReply(hello.messageId)); + await ready; + expect(cancel).toHaveBeenCalledTimes(1); + const timedOut = client.request("intake.list", {}).catch((error: unknown) => error); + expect(schedule).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(READ_TIMEOUT_MS); + expect(await timedOut).toMatchObject({ code: "timeout" }); + expect(client.pendingCount).toBe(0); + const cancelled = client.request("intake.list", {}).catch((error: unknown) => error); + expect(schedule).toHaveBeenCalledTimes(3); + client.close(); + expect(await cancelled).toMatchObject({ code: "channel-reset" }); + expect(cancel).toHaveBeenCalledTimes(2); + } finally { + client.close(); + schedule.mockRestore(); + cancel.mockRestore(); + vi.useRealTimers(); + } +}); diff --git a/apps/web/src/lib/mobile/bridgeClient.ts b/apps/web/src/lib/mobile/bridgeClient.ts index 32589b482..1112b17fc 100644 --- a/apps/web/src/lib/mobile/bridgeClient.ts +++ b/apps/web/src/lib/mobile/bridgeClient.ts @@ -65,6 +65,7 @@ export interface HandshakeResult { backgroundLocation: boolean; localNotifications: boolean; speech: boolean; + nativeIntake?: boolean; carDestinations?: boolean; }; permission: string; @@ -95,6 +96,9 @@ type RequestOptions = { }; const EXPECTED_REPLY: Partial> = { + "intake.list": "intake.items", + "intake.read": "intake.chunk", + "intake.complete": "intake.completed", "web.hello": "native.hello", "voice.probe": "voice.availability", "session.prepare": "session.prepared", @@ -128,7 +132,16 @@ export class BridgeClient { private messageCounter = 0; private detach: (() => void) | null = null; - constructor(private readonly options: BridgeClientOptions) {} + private readonly scheduleTimeout: NonNullable; + private readonly cancelTimeout: NonNullable; + + constructor(private readonly options: BridgeClientOptions) { + this.scheduleTimeout = + options.setTimeout ?? ((callback, ms) => globalThis.setTimeout(callback, ms)); + this.cancelTimeout = + options.clearTimeout ?? + ((handle) => globalThis.clearTimeout(handle as ReturnType)); + } get isAvailable(): boolean { return this.nonce !== null && this.transport !== null; @@ -169,7 +182,7 @@ export class BridgeClient { close(reason: BridgeFailure = "channel-reset"): void { this.closed = true; for (const [, entry] of this.pending) { - this.options.clearTimeout?.(entry.timer); + this.cancelTimeout(entry.timer); entry.reject(new BridgeError(reason)); } this.pending.clear(); @@ -203,12 +216,17 @@ export class BridgeClient { if (reply.type !== "native.hello") throw new BridgeError("invalid-response"); const hello = reply.payload; + const selected = hello.selectedProtocolVersion; if ( - reply.protocolVersion !== MOBILE_PROTOCOL_MAX || - (hello.selectedProtocolVersion !== null && - (hello.selectedProtocolVersion !== MOBILE_PROTOCOL_MAX || - hello.selectedProtocolVersion < hello.minProtocolVersion || - hello.selectedProtocolVersion > hello.maxProtocolVersion)) + hello.minProtocolVersion > hello.maxProtocolVersion || + (selected === null + ? reply.protocolVersion < hello.minProtocolVersion || + reply.protocolVersion > hello.maxProtocolVersion + : reply.protocolVersion !== selected || + selected < MOBILE_PROTOCOL_MIN || + selected > MOBILE_PROTOCOL_MAX || + selected < hello.minProtocolVersion || + selected > hello.maxProtocolVersion) ) { throw new BridgeError("invalid-response"); } @@ -258,7 +276,7 @@ export class BridgeClient { if (!validated.success) return Promise.reject(new BridgeError("invalid-command")); return new Promise((resolve, reject) => { - const timer = this.options.setTimeout?.(() => { + const timer = this.scheduleTimeout(() => { this.pending.delete(messageId); reject(new BridgeError("timeout")); }, options.timeoutMs ?? READ_TIMEOUT_MS); @@ -268,7 +286,7 @@ export class BridgeClient { this.transport?.postMessage(JSON.stringify(validated.data)); } catch { this.pending.delete(messageId); - this.options.clearTimeout?.(timer); + this.cancelTimeout(timer); reject(new BridgeError("no-transport")); } }); @@ -331,7 +349,7 @@ export class BridgeClient { const entry = key ? this.pending.get(key) : undefined; if (entry && key) { this.pending.delete(key); - this.options.clearTimeout?.(entry.timer); + this.cancelTimeout(entry.timer); const expectedType = EXPECTED_REPLY[entry.requestType]; if (message.type !== "native.error" && expectedType !== message.type) { entry.reject(new BridgeError("invalid-response")); diff --git a/apps/web/src/lib/mobile/nativeIntake.test.ts b/apps/web/src/lib/mobile/nativeIntake.test.ts new file mode 100644 index 000000000..bc555e07d --- /dev/null +++ b/apps/web/src/lib/mobile/nativeIntake.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { NativeIntakeClient } from "./nativeIntake"; + +const id = "f386e706-b728-40a3-9554-881d0ff0958a"; +const item = { id, createdAtMs: 1, kind: "text" as const, name: "Shared location", byteLength: 6 }; +describe("native intake transfer", () => { + it("checks exact bytes/offset/EOF without acknowledging reads", async () => { + const request = vi.fn(async () => ({ + type: "intake.chunk", + payload: { + id, + offset: 0, + data: btoa("Berlin"), + byteLength: 6, + eof: true, + forMessageId: "r1", + }, + })); + const client = new NativeIntakeClient({ request } as never); + expect(await client.text(item, new AbortController().signal)).toBe("Berlin"); + expect(request.mock.calls).toHaveLength(1); + }); + it.each([ + { offset: 24576 }, + { eof: false }, + { byteLength: 5 }, + { data: "not-base64" }, + { id: "different" }, + { data: btoa("1234567") }, + ])("rejects corrupt chunks %j", async (patch) => { + const request = vi.fn(async () => ({ + type: "intake.chunk", + payload: { id, offset: 0, data: btoa("Berlin"), byteLength: 6, eof: true, ...patch }, + })); + await expect( + new NativeIntakeClient({ request } as never).text(item, new AbortController().signal), + ).rejects.toThrow(); + }); + it("rejects cancellation and invalid UTF8", async () => { + const request = vi.fn(async () => ({ + type: "intake.chunk", + payload: { id, offset: 0, data: btoa("\xff\xff\xff\xff\xff\xff"), byteLength: 6, eof: true }, + })); + const client = new NativeIntakeClient({ request } as never); + await expect(client.text(item, new AbortController().signal)).rejects.toThrow(); + const abort = new AbortController(); + abort.abort(); + await expect(client.text(item, abort.signal)).rejects.toThrow(); + }); +}); + +it("validates metadata before allocating and enforces the total transfer deadline", async () => { + const request = vi.fn(async () => ({ + type: "intake.chunk", + payload: { id, offset: 0, data: btoa("Berlin"), byteLength: 6, eof: true }, + })); + const client = new NativeIntakeClient({ request } as never); + await expect( + client.text({ ...item, byteLength: 999999999 }, new AbortController().signal), + ).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + let calls = 0; + const clock = vi.spyOn(Date, "now").mockImplementation(() => (++calls < 4 ? 1000 : 62000)); + try { + await expect(client.text(item, new AbortController().signal)).rejects.toThrow(); + } finally { + clock.mockRestore(); + } +}); +it("reads every aligned chunk and verifies the short final chunk", async () => { + const request = vi.fn(async (...args: unknown[]) => { + const payload = args[1] as { offset: number }; + const count = Math.min(24576, 50000 - payload.offset); + return { + type: "intake.chunk", + payload: { + id, + offset: payload.offset, + data: btoa("x".repeat(count)), + byteLength: count, + eof: payload.offset + count === 50000, + }, + }; + }); + const client = new NativeIntakeClient({ request } as never); + expect( + await client.text( + { ...item, kind: "file", format: "gpx", name: "route.gpx", byteLength: 50000 }, + new AbortController().signal, + ), + ).toHaveLength(50000); + expect(request.mock.calls.map((call) => (call[1] as { offset: number }).offset)).toEqual([ + 0, 24576, 49152, + ]); +}); + +it("keeps closed native failure codes without exposing arbitrary native error text", async () => { + const request = vi.fn(async () => ({ + type: "native.error", + payload: { code: "expired", forMessageId: "r1" }, + })); + const client = new NativeIntakeClient({ request } as never); + await expect(client.text(item, new AbortController().signal)).rejects.toMatchObject({ + code: "expired", + }); + request.mockResolvedValue({ + type: "native.error", + payload: { code: "/private/sender-path", forMessageId: "r2" }, + }); + await expect(client.list()).rejects.toMatchObject({ code: "unavailable" }); +}); + +it("preserves unavailable navigation authority as a distinct bridge error", async () => { + const client = new NativeIntakeClient({ + request: async () => ({ + type: "native.error", + payload: { code: "navigation-unavailable", forMessageId: "request" }, + }), + } as never); + await expect(client.list()).rejects.toMatchObject({ code: "navigation-unavailable" }); +}); diff --git a/apps/web/src/lib/mobile/nativeIntake.ts b/apps/web/src/lib/mobile/nativeIntake.ts new file mode 100644 index 000000000..4e95eed8c --- /dev/null +++ b/apps/web/src/lib/mobile/nativeIntake.ts @@ -0,0 +1,73 @@ +import { + INTAKE_CHUNK_BYTES, + INTAKE_ERROR_CODES, + type IntakeItem, + intakeChunkSchema, + intakeItemSchema, + intakeListingSchema, + type NativeToWebMessage, +} from "@openmapx/core/navigation"; +import type { BridgeClient } from "./bridgeClient"; +export class IntakeTransferError extends Error { + constructor(readonly code: string) { + super(code); + } +} +function rejectNativeError(reply: NativeToWebMessage): void { + if (reply.type !== "native.error") return; + const code = reply.payload.code; + throw new IntakeTransferError( + (INTAKE_ERROR_CODES as readonly string[]).includes(code) || + code === "navigation-active" || + code === "navigation-unavailable" + ? code + : "unavailable", + ); +} +export class NativeIntakeClient { + constructor(private readonly client: Pick) {} + async list() { + const reply = await this.client.request("intake.list", {}); + rejectNativeError(reply); + if (reply.type !== "intake.items") throw new IntakeTransferError("unavailable"); + const { forMessageId: _, ...payload } = reply.payload; + return intakeListingSchema.parse(payload); + } + async text(item: IntakeItem, signal: AbortSignal): Promise { + item = intakeItemSchema.parse(item); + if (item.kind === "error") throw new IntakeTransferError(item.errorCode); + const deadline = Date.now() + 60000; + const bytes = new Uint8Array(item.byteLength); + for (let offset = 0; offset < item.byteLength; offset += INTAKE_CHUNK_BYTES) { + if (signal.aborted || Date.now() >= deadline) throw new IntakeTransferError("unavailable"); + const reply = await this.client.request( + "intake.read", + { id: item.id, offset }, + { timeoutMs: Math.min(5000, deadline - Date.now()) }, + ); + rejectNativeError(reply); + if (signal.aborted || Date.now() >= deadline || reply.type !== "intake.chunk") + throw new IntakeTransferError("unavailable"); + const { forMessageId: _, ...payload } = reply.payload; + const chunk = intakeChunkSchema.parse(payload); + const count = Math.min(INTAKE_CHUNK_BYTES, item.byteLength - offset); + if ( + chunk.id !== item.id || + chunk.offset !== offset || + chunk.byteLength !== count || + chunk.eof !== (offset + count === item.byteLength) + ) + throw new IntakeTransferError("invalid-data"); + const decoded = atob(chunk.data); + if (decoded.length !== count) throw new IntakeTransferError("invalid-data"); + for (let i = 0; i < count; i++) bytes[offset + i] = decoded.charCodeAt(i); + } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } + async complete(id: string, disposition: "opened" | "discarded") { + const reply = await this.client.request("intake.complete", { id, disposition }); + rejectNativeError(reply); + if (reply.type !== "intake.completed" || reply.payload.id !== id) + throw new IntakeTransferError("unavailable"); + } +} diff --git a/apps/web/src/lib/mobile/navigationCommands.ts b/apps/web/src/lib/mobile/navigationCommands.ts index af549ad0b..f7e31da34 100644 --- a/apps/web/src/lib/mobile/navigationCommands.ts +++ b/apps/web/src/lib/mobile/navigationCommands.ts @@ -80,6 +80,10 @@ function asCommandError(error: unknown): CommandError { export class NativeNavigationCommands { /** Mutations queue behind this; reads deliberately do not. */ private queue: Promise = Promise.resolve(); + private pendingMutations = 0; + get isMutating(): boolean { + return this.pendingMutations > 0; + } constructor( private readonly client: BridgeClient, @@ -93,7 +97,10 @@ export class NativeNavigationCommands { * not strand every later one, while the caller still sees its own failure. */ private serialize(task: () => Promise): Promise { - const result = this.queue.then(task, task); + this.pendingMutations++; + const result = this.queue.then(task, task).finally(() => { + this.pendingMutations--; + }); this.queue = result.then( () => undefined, () => undefined, diff --git a/apps/web/src/lib/validateImportedGeometry.test.ts b/apps/web/src/lib/validateImportedGeometry.test.ts new file mode 100644 index 000000000..c482eab67 --- /dev/null +++ b/apps/web/src/lib/validateImportedGeometry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseGeoFile } from "./importGeoFile"; + +describe("bounded geographic imports", () => { + it.each([ + { type: "Bogus", coordinates: [8, 50] }, + { type: "Point", coordinates: [181, 50] }, + { type: "Point", coordinates: [8, 91] }, + { type: "Point", coordinates: [null, 50] }, + { type: "LineString", coordinates: [[8, 50]] }, + ])("rejects invalid geometry %j", (geometry) => { + expect(parseGeoFile("shared.geojson", JSON.stringify(geometry))).toBeNull(); + }); + it("rejects deep collections, huge feature sets and JSON nesting before parsing", () => { + let geometry: unknown = { type: "Point", coordinates: [8, 50] }; + for (let i = 0; i < 10; i++) geometry = { type: "GeometryCollection", geometries: [geometry] }; + expect(parseGeoFile("shared.geojson", JSON.stringify(geometry))).toBeNull(); + const feature = { + type: "Feature", + properties: {}, + geometry: { type: "Point", coordinates: [8, 50] }, + }; + expect( + parseGeoFile( + "shared.json", + JSON.stringify({ type: "FeatureCollection", features: Array(10001).fill(feature) }), + ), + ).toBeNull(); + expect(parseGeoFile("shared.json", `${"[".repeat(1000)}0${"]".repeat(1000)}`)).toBeNull(); + }); + it("rejects XML declarations before invoking DOMParser", () => { + const parser = vi.spyOn(globalThis, "DOMParser"); + try { + expect( + parseGeoFile("route.gpx", ']>&x;'), + ).toBeNull(); + expect(parseGeoFile("route.kml", '')).toBeNull(); + expect(parser).not.toHaveBeenCalled(); + } finally { + parser.mockRestore(); + } + }); + it("keeps ordinary browser .json and drops remote/executable KML metadata", () => { + const parsed = parseGeoFile( + "shared.json", + JSON.stringify({ type: "Point", coordinates: [8, 50] }), + ); + expect(parsed?.features).toHaveLength(1); + const kml = + ']]>8,50'; + expect(parseGeoFile("shared.kml", kml)?.features[0].properties?.description).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/validateImportedGeometry.ts b/apps/web/src/lib/validateImportedGeometry.ts new file mode 100644 index 000000000..2267405fe --- /dev/null +++ b/apps/web/src/lib/validateImportedGeometry.ts @@ -0,0 +1,87 @@ +/** Bounds apply before rendering or converter traversal; no network resources are resolved. */ +export const MAX_IMPORT_BYTES = 5 * 1024 * 1024; +export function boundedImportText(text: string): boolean { + if (text.length > MAX_IMPORT_BYTES || new TextEncoder().encode(text).length > MAX_IMPORT_BYTES) + return false; + let depth = 0; + let quoted = false; + let escaped = false; + for (const char of text) { + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + } else if (char === '"') quoted = true; + else if (char === "{" || char === "[") { + if (++depth > 64) return false; + } else if (char === "}" || char === "]") depth--; + } + return true; +} +export function validImportedCollection(fc: GeoJSON.FeatureCollection): boolean { + if (fc.features.length < 1 || fc.features.length > 10000) return false; + let positions = 0; + function position(value: unknown): value is GeoJSON.Position { + if ( + !Array.isArray(value) || + value.length < 2 || + value.length > 3 || + !value.every((n) => typeof n === "number" && Number.isFinite(n)) + ) + return false; + return Math.abs(value[0]) <= 180 && Math.abs(value[1]) <= 90 && ++positions <= 100000; + } + function line(value: unknown, min: number): boolean { + return Array.isArray(value) && value.length >= min && value.every(position); + } + function polygon(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (ring) => + line(ring, 4) && ring[0].every((n: number, i: number) => n === ring[ring.length - 1][i]), + ) + ); + } + function geometry(value: unknown, depth: number): boolean { + if (!value || typeof value !== "object" || depth > 8) return false; + const g = value as { type?: unknown; coordinates?: unknown; geometries?: unknown }; + switch (g.type) { + case "Point": + return position(g.coordinates); + case "MultiPoint": + return line(g.coordinates, 1); + case "LineString": + return line(g.coordinates, 2); + case "MultiLineString": + return ( + Array.isArray(g.coordinates) && + g.coordinates.length > 0 && + g.coordinates.every((l) => line(l, 2)) + ); + case "Polygon": + return polygon(g.coordinates); + case "MultiPolygon": + return ( + Array.isArray(g.coordinates) && g.coordinates.length > 0 && g.coordinates.every(polygon) + ); + case "GeometryCollection": + return ( + Array.isArray(g.geometries) && + g.geometries.length > 0 && + g.geometries.every((g) => geometry(g, depth + 1)) + ); + default: + return false; + } + } + return fc.features.every( + (f) => + f && + f.type === "Feature" && + (f.properties === null || + (typeof f.properties === "object" && !Array.isArray(f.properties))) && + geometry(f.geometry, 0), + ); +} diff --git a/docs/docs/developer/android-auto.md b/docs/docs/developer/android-auto.md index 2c8752c59..32fb469fb 100644 --- a/docs/docs/developer/android-auto.md +++ b/docs/docs/developer/android-auto.md @@ -155,12 +155,14 @@ those outcomes. ### Destinations, planning and start policy -The phone page publishes `destinations.update` only after protocol v3 negotiation +The phone page publishes `destinations.update` only after phone-bridge negotiation reports the optional `carDestinations` capability. The schema permits at most 20 recent coordinate-bearing selections and 50 saved places, IDs up to 128 characters, labels up to 160, finite WGS84 `[longitude, latitude]` coordinates, locale/units and captured routing/voice preferences. The destinations payload has a 64 KiB ceiling. -Query-only recent searches are not given invented coordinates. +New shells advertise this capability to protocol-4 peers; protocol-3 navigation +stays compatible with older strict hello schemas. Query-only recent searches +are not given invented coordinates. Saved places are account-keyed; recent selections are scoped to the current account. The shell stores **one current instance/account projection** in SQLite, @@ -226,7 +228,7 @@ and speaker routing cannot be accepted from DHU alone. ## Car surface protocol Every JSON message has `{ v: 1, type, ... }`. This is separate from the nested -phone bridge's protocol v3. The page sends through +phone bridge's negotiated protocol 3/4. The page sends through `OpenMapXCar.postMessage(JSON.stringify(message))`; native replies through `JavaScriptReplyProxy`, delivered to `OpenMapXCar.onmessage`. diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 2257a7a68..1dc754be4 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -126,9 +126,9 @@ After a refusal it answers `denied` without prompting again. ## The bridge protocol -Both sides currently support protocol **v3 only**. The constants in +Both sides support protocols **v3 through v4**. The constants in `packages/core/src/navigation/mobileProtocol.ts` and the release descriptor in -`apps/mobile/release/version.json` agree on `min: 3, max: 3`. A non-overlapping +`apps/mobile/release/version.json` agree on `min: 3, max: 4`. Protocol 3 retains navigation; protocol 4 adds the optional `nativeIntake` capability. A non-overlapping range leaves navigation disabled and requires an update. Protocol negotiation alone does not establish that a command is implemented. @@ -136,8 +136,13 @@ alone does not establish that a command is implemented. Before changing the protocol, identify the actually deployed shell and web versions. Define and test their overlap, deploy a compatible web build, then ship -the native build and enable the new capability. The current first-candidate -configuration has no older version in its compatibility matrix. +the native build and enable the new capability. New web builds accept older protocol-3 shells. New shells omit `nativeIntake` +from protocol-3 hello replies and suppress intake events. Android Auto’s optional +`carDestinations` key is also omitted for these older strict hello schemas; a +protocol-4 peer receives both capabilities. Queued navigation +messages are stamped only after negotiation, so a pre-handshake restore remains +readable by the older page. Pending inbox items survive until a compatible page +opens them, the user discards them, or their 24-hour expiry. Voice input uses a correlated `voice.probe` / `voice.availability` exchange after negotiation, without changing either strict hello payload or the protocol version. @@ -385,3 +390,124 @@ For App Store distribution, register and provision the additional `.NavigationSurface` App ID. Its versions and signing team follow the host application. Simulator builds and screenshots do not establish physical-device background/force-quit behavior or App Store signing acceptance. + +## Native sharing and geographic files + +Android receives text/URI sharing and typed GPX, GeoJSON and KML attachments +through a dedicated, nonhistorical receiver activity. Document VIEW handles +`content:` grants, without BROWSABLE or storage/media permissions. The receiver +opens the React task first with a neutral MAIN intent without grants or URIs. +A separate MAIN delivery to the existing singleTask activity forwards only read +grants while background copying finishes; those grants do not enter the task’s +root intent. The task never retains the original share +intent, avoiding process-death replay even when Expo discards saved state. +Restored receiver activities and history launches are ignored. Cold and warm +intake does not depend on Expo or JavaScript startup; ordinary map/End Linking +ignores document URLs. + +Android copies foreign providers into `noBackupFilesDir` while grants are valid. +It rejects `file:` URLs and positively identified own providers; package-visibility +lookup failure is not a refusal. Pipe-backed providers use nonblocking polling, +actual byte counts, cancellation and a ten-second deadline that closes descriptors. +Two workers and a bounded queue limit unresponsive provider work. Runtime extension +validation rejects unsupported/mixed attachments even if an explicit intent or +wildcard sender reaches the activity. Generic MIME fallbacks are not claimed; +provider MIME compatibility must be checked with the intended Files/mail app. + +On iOS, `OpenMapXShareExtension` saves user-confirmed items into +`group..intake`. It completes the extension request and asks the user to +open OpenMapX manually. It never launches the host through unsupported APIs. +Only the host and Share Extension have this group; the navigation widget has none. +The group contains intake bytes only, never authentication or navigation storage. +Files Open With requires `LSSupportsOpeningDocumentsInPlace=true`. For an OS-delivered +in-place URL, the host requires a positive security-scope grant, asynchronously +coordinates the read and copies at most 5 MiB into owned staging within a ten-second +deadline. Scope is acquired on the worker and released on success, failure, timeout +or background-task expiration. The provider's original is never edited or deleted; +no bookmark or grant is retained. The descriptor is opened without following +symlinks or blocking on FIFOs, checked with `fstat`, and rejected if its resolved +path is in the app's data container or intake App Group. The private inbox commit +runs after the external accessor exits. + +The ten-second budget starts when the OS delivers a URL and includes waiting on +the serial host queue, so an item behind a slow provider can expire before reading. +A cancellation racing the final private rename can leave a completed inbox item +alongside an unreadable notice; the immutable item remains available for review. + +For non-in-place OS copies, only regular, non-symlink direct children of +`Documents/Inbox` are accepted; the temporary OS copy is removed after handling, and abandoned +copies expire on launch. `UIFileSharingEnabled` and `UISupportsDocumentBrowser` +stay absent. Opening a selected external document does not expose the app container +in Files. RN Linking never grants file access. + +Android document channels reset only on the vendor-tagged native `onPageStarted` +event. History callbacks still update navigation/back state, but cannot reset the +bridge even when Chromium reports `loading=true` after a finish. A missing tag in +a stale development APK fails closed. Native load-end handling is idempotent, +failed loads retain their error state, and bridge requests always have production +deadlines. The per-document nonce remains main-frame-only. Fast consecutive real +loads can still race nonce-prop delivery; iOS policy starts can include a fragment +or a navigation that never commits. These are separate qualification limits, not +reasons to accept an old channel or fall back to browser navigation. + +The Foundation store coordinates short commits across the host and extension. +External provider reads do not hold the coordinator lock. Staging is separated by +writer and named with a writer-recorded epoch; cleanup waits beyond the copy +deadline plus a minute, preserving a live writer. Host document commits have a +background-task assertion. The host module and extension include file-access +privacy manifests. Their [required-reason declarations](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) +cover app/group metadata (`C617.1`), selected-file metadata (`3B52.1`) and monotonic +deadline timers (`35F9.1`). No uptime or metadata is transmitted. The extension imports GPX `com.topografix.gpx`, KML +`com.google.earth.kml`, and Apple's `public.geojson` (also declared for older OS +versions). Its bounded SUBQUERY activation predicate excludes arbitrary file URLs. +URL and text attachments are combined into one editable location, retaining +labels and the first link; irrelevant preview attachments are ignored in this +mode. Geographic files must all be supported. Apple declares `public.geojson` +on iOS 18 and later; its imported declaration supports older OS versions, whose +installation and Files registration still require device qualification. + +Limits are 4,096 UTF-16 units/8 KiB for text, 5 MiB per file, five files per share, +and eight content items/20 MiB for the owned inbox. Two bounded error descriptors +remain available when content is full. Multi-file copies commit individually: if +a later attachment fails, earlier successful entries remain alongside an error +notice. Items expire after 24 hours. Names are +bounded display labels; only opaque UUIDs address immutable entries. Protocol-4 +list/read/complete commands transfer stateless 24 KiB chunks through the existing +origin/nonce-authenticated bridge. No native path, sender URI or server authority +crosses it. Reads never acknowledge an item; explicit Open/Import or Discard does. +Older pages receive a native update notice and preserve their pending items. + +The web preview validates UTF-8 and exact chunk boundaries with a 60-second total +transfer limit. Text is classified locally; short URLs are never fetched. Users +can edit an unsupported link into an address. Geographic parsing rejects XML +entities/DOCTYPE before DOMParser, invalid/out-of-range geometry and oversized or +deep collections. It permits at most 100,000 positions and 10,000 features. +KML description/icon/style URL metadata is dropped. Browser `.json` import remains +supported; native document registration is limited to `.gpx`, `.geojson`, `.kml`. + +The inbox entry point returns non-map pages to the map before offering preview. +A route change cancels pending application. Confirmation applies a map search +or the existing imported overlay. It never adds +saved places automatically. Replacing an overlay is explicit. A fresh native +inbox listing supplies navigation availability immediately before application, +and the page rechecks its native session and pending mutations synchronously. +Preparing, active, arrived or unresolved navigation defers application. Intake +never terminates or replaces a trip; Later keeps the item and Discard removes only +that item. + +Platform references: [Expo incoming sharing](https://docs.expo.dev/versions/v57.0.0/sdk/sharing/) +labels its iOS host-launch approach experimental and unsupported by Apple; +[Apple's extension guide](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html) +documents App Groups and activation predicates; the +[Android receiver guide](https://developer.android.com/develop/ui/compose/sharing/receive) +and [ContentResolver security guidance](https://developer.android.com/privacy-and-security/risks/content-resolver) +define the Android receiving and provider authority boundaries. +[Apple’s GeoJSON type](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/geojson) +confirms the `public.geojson` identifier. + +Intake resolves supported Apple unified and Google long map links locally without +following short-link redirects. When native trip authority is unavailable, it +fails closed with reopen/recheck guidance and keeps shared items. This differs +from a known active trip, which remains deferred until navigation ends. The +development feasibility mode intentionally omits normal navigation composition +and allows intake. diff --git a/docs/docs/developer/mobile-store-operations.md b/docs/docs/developer/mobile-store-operations.md index 6cb744910..addebb7f3 100644 --- a/docs/docs/developer/mobile-store-operations.md +++ b/docs/docs/developer/mobile-store-operations.md @@ -203,3 +203,20 @@ with higher version/build values; a halt does not restore an older binary. dependency changes; test with compatibility mode disabled. - Derive protocol changes from the deployed-version matrix. - Revalidate association files and device links after service or signing changes. + +## Share Extension provisioning + +Register both `.ShareExtension` and `group..intake` in the Apple +Developer account, and enable that exact App Group for the host and Share +Extension provisioning profiles. The navigation widget must not receive the group. +The extension inherits host versions and the configured Apple team. Rebuild native +projects through CNG; do not edit generated targets or entitlements. Automatic +App Store export still requires all App IDs and profiles to exist. Simulator +builds do not establish production signing or App Store acceptance. + +Reviewers can share a location or GPX/GeoJSON/KML file, choose OpenMapX, tap +Add, finish the extension, then open the app to review/import it. The extension +does not auto-launch the containing app. Test both this path and Files Open In, +including cold delivery and an active trip. Shared items are local, bounded and +expire after 24 hours. Import confirmation can initiate ordinary map/search +requests; no sender URL is fetched or used to select a backend. diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts index 8c2e5a38a..af3fba0a7 100644 --- a/packages/core/src/navigation/index.ts +++ b/packages/core/src/navigation/index.ts @@ -169,6 +169,7 @@ export { type SessionParseResult, type TransitMobileSession, } from "./mobileSession"; +export * from "./nativeIntake"; export { createNavigationSessionSnapshot, isNavigationSessionExpired, diff --git a/packages/core/src/navigation/mobileProtocol.ts b/packages/core/src/navigation/mobileProtocol.ts index 8315984e1..31b6a2fad 100644 --- a/packages/core/src/navigation/mobileProtocol.ts +++ b/packages/core/src/navigation/mobileProtocol.ts @@ -1,6 +1,13 @@ import { parseContributors } from "@openmapx/mobility-core/contributors"; import { parseGeometryContributors } from "@openmapx/mobility-core/geometry-contributors"; import { z } from "zod"; +import { + INTAKE_CHUNK_BYTES, + intakeChunkSchema, + intakeIdSchema, + intakeItemSchema, + MAX_INTAKE_FILE_BYTES, +} from "./nativeIntake"; /** * The complete wire contract between the OpenMapX web UI and the installed @@ -42,7 +49,7 @@ const MAX_TEXT_LENGTH = 512; // Versions. export const MOBILE_PROTOCOL_MIN = 3; -export const MOBILE_PROTOCOL_MAX = 3; +export const MOBILE_PROTOCOL_MAX = 4; export interface ProtocolRange { min: number; @@ -64,6 +71,9 @@ export function negotiateMobileProtocol(web: ProtocolRange, native: ProtocolRang export const WEB_TO_NATIVE_TYPES = [ "web.hello", + "intake.list", + "intake.read", + "intake.complete", "destinations.update", "session.prepare", "session.start", @@ -86,6 +96,10 @@ export const WEB_TO_NATIVE_TYPES = [ export const NATIVE_TO_WEB_TYPES = [ "native.hello", + "intake.items", + "intake.chunk", + "intake.completed", + "intake.changed", "permission.state", "session.prepared", "session.started", @@ -335,6 +349,7 @@ export const nativeCapabilitiesSchema = z backgroundLocation: z.boolean(), localNotifications: z.boolean(), speech: z.boolean(), + nativeIntake: z.boolean().optional(), carDestinations: z.boolean().optional(), }) .strict(); @@ -396,6 +411,25 @@ export type CarDestination = z.infer; export type CarDestinations = z.infer; export const webToNativeSchema = z.discriminatedUnion("type", [ + envelope("intake.list", z.object({}).strict()), + envelope( + "intake.read", + z + .object({ + id: intakeIdSchema, + offset: z + .number() + .int() + .nonnegative() + .max(MAX_INTAKE_FILE_BYTES) + .refine((n) => n % INTAKE_CHUNK_BYTES === 0), + }) + .strict(), + ), + envelope( + "intake.complete", + z.object({ id: intakeIdSchema, disposition: z.enum(["opened", "discarded"]) }).strict(), + ), envelope("destinations.update", carDestinationsSchema), envelope("voice.probe", z.object({}).strict()), envelope( @@ -541,6 +575,20 @@ export const voiceEventSchema = z export type VoiceEvent = z.infer; export const nativeToWebSchema = z.discriminatedUnion("type", [ + envelope( + "intake.items", + z + .object({ + items: z.array(intakeItemSchema).max(10), + canApply: z.boolean(), + scheme: z.string().regex(/^[a-z][a-z0-9+.-]{0,100}$/), + forMessageId: boundedId, + }) + .strict(), + ), + envelope("intake.chunk", intakeChunkSchema.extend({ forMessageId: boundedId })), + envelope("intake.completed", z.object({ id: intakeIdSchema, forMessageId: boundedId }).strict()), + envelope("intake.changed", z.object({ pendingCount: z.number().int().min(0).max(10) }).strict()), envelope( "voice.availability", z.object({ forMessageId: boundedId, available: z.boolean() }).strict(), diff --git a/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts new file mode 100644 index 000000000..ea591caa9 --- /dev/null +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + classifySharedText, + INTAKE_CHUNK_BYTES, + intakeItemSchema, + MAX_INTAKE_FILE_BYTES, +} from "./nativeIntake"; + +const config = { webOrigin: "https://openmapx.com", scheme: "openmapx" }; +const params = (text: string) => { + const result = classifySharedText(text, config); + expect(result.kind).toBe("map"); + if (result.kind !== "map") throw new Error("not a map intent"); + return new URLSearchParams(result.query); +}; +describe("native intake contracts", () => { + it("bounds metadata and never accepts a native file path", () => { + const item = { + id: "12345678-1234-4234-8234-123456789abc", + kind: "file", + name: "route.gpx", + format: "gpx", + createdAtMs: 1000, + byteLength: 42, + }; + expect(intakeItemSchema.safeParse(item).success).toBe(true); + expect(intakeItemSchema.safeParse({ ...item, path: "/private/secret" }).success).toBe(false); + expect( + intakeItemSchema.safeParse({ ...item, byteLength: MAX_INTAKE_FILE_BYTES + 1 }).success, + ).toBe(false); + expect(intakeItemSchema.safeParse({ ...item, id: "../secret" }).success).toBe(false); + expect(INTAKE_CHUNK_BYTES).toBe(24576); + }); +}); +describe("classifySharedText", () => { + it("searches ordinary addresses without choosing another server", () => { + expect(params(" Bahnhofstraße 12, München ").get("q")).toBe("Bahnhofstraße 12, München"); + }); + it.each(["50.11,8.68", "geo:50.11,8.68", "geo:0,0?q=50.11,8.68(Station)"])( + "recognizes an explicit point: %s", + (value) => { + const p = params(value); + expect(p.get("at")).toBe("50.11,8.68"); + expect(p.get("panel")).toBe("place"); + }, + ); + it("uses an address query in geo URIs", () => + expect(params("geo:0,0?q=Frankfurt+Hauptbahnhof").get("q")).toBe("Frankfurt Hauptbahnhof")); + it.each([ + "https://maps.apple.com/?ll=50.11,8.68&q=Station", + "https://www.google.com/maps/search/?api=1&query=50.11,8.68", + "https://www.openstreetmap.org/?mlat=50.11&mlon=8.68#map=15/50.11/8.68", + ])("uses coordinates from a recognized map link: %s", (value) => + expect(params(value).get("at")).toBe("50.11,8.68"), + ); + it("retains a useful label beside an unresolved short link without fetching", () => + expect(params("Frankfurt Hauptbahnhof\nhttps://maps.app.goo.gl/example").get("q")).toBe( + "Frankfurt Hauptbahnhof", + )); + it("accepts own-origin map links without accepting another backend", () => + expect(params("https://openmapx.com/?q=Station").get("q")).toBe("Station")); + it.each([ + "file:///private/route.gpx", + "https://evil.example/?q=Station", + "https://openmapx.com.evil.example/?q=x", + "https://openmapx.com:444/?q=x", + "https://user@openmapx.com/?q=x", + "javascript:alert(1)", + "91,8", + "geo:91,8", + "", + "a".repeat(4097), + ])("refuses ambiguous or hostile input: %s", (value) => + expect(classifySharedText(value, config).kind).toBe("unsupported"), + ); +}); + +it("retains explicit geo coordinates when a sender also provides a location label", () => { + expect( + classifySharedText("Station\ngeo:50,8", { + webOrigin: "https://openmapx.com", + scheme: "openmapx", + }), + ).toMatchObject({ kind: "map" }); +}); + +it.each([ + "tel:+493012345", + "Tel:+493012345", + "mailto:cafe@example.com", + "https://example.com/menu", +])("does not let %s hide a later map link", (line) => { + expect(params(`Café\n${line}\nhttps://maps.google.com/?q=52.5,13.4`).get("at")).toBe("52.5,13.4"); +}); + +it.each([ + [ + "https://maps.apple.com/place?coordinate=48.137,11.575&name=Marienplatz", + "48.137,11.575", + "Marienplatz", + ], + ["https://maps.apple.com/frame?center=48.137,11.575", "48.137,11.575", "48.137,11.575"], + [ + "https://www.google.com/maps/place/Marienplatz/@48.137,11.575,17z", + "48.137,11.575", + "Marienplatz", + ], +])("recognizes current map URLs: %s", (url, at, name) => { + expect(params(url).get("at")).toBe(at); + expect(params(url).get("name")).toBe(name); +}); +it("recognizes unified Apple search URLs", () => { + expect(params("https://maps.apple.com/search?query=coffee").get("q")).toBe("coffee"); +}); +it("uses a decoded Google place name when no explicit point is available", () => { + expect(params("https://www.google.com/maps/place/Caf%C3%A9+Central/").get("q")).toBe( + "Café Central", + ); +}); +it.each([ + "https://maps.apple.com/place?coordinate=91,11&name=Station", + "https://www.google.com/maps/place/Station/@91,11,17z", + "https://www.google.com/maps/place/%ZZ/", + "https://maps.apple/p/example", + "https://maps.app.goo.gl/example", +])("does not fetch short links or accept invalid map locations: %s", (url) => { + expect(classifySharedText(url, config).kind).toBe("unsupported"); +}); + +it("does not turn a Google route viewport into a place", () => { + expect( + classifySharedText("https://www.google.com/maps/dir/Berlin/Munich/@50,11,7z", config).kind, + ).toBe("unsupported"); +}); +it("keeps the Google search term beside a viewport", () => { + expect(params("https://www.google.com/maps/search/coffee/@48.137,11.575,15z").get("q")).toBe( + "coffee", + ); +}); +it("recognizes a standalone Google map viewport", () => { + expect(params("https://www.google.com/maps/@48.137,11.575,15z").get("at")).toBe("48.137,11.575"); +}); diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts new file mode 100644 index 000000000..776769bd9 --- /dev/null +++ b/packages/core/src/navigation/nativeIntake.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; + +export const MAX_INTAKE_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_INTAKE_TEXT_BYTES = 8192; +export const MAX_INTAKE_TEXT_LENGTH = 4096; +export const MAX_INTAKE_ITEMS = 8; +export const MAX_INTAKE_INBOX_BYTES = 20 * 1024 * 1024; +export const MAX_INTAKE_FILES_PER_SHARE = 5; +export const INTAKE_TTL_MS = 24 * 60 * 60 * 1000; +export const INTAKE_CHUNK_BYTES = 24576; +export const INTAKE_ERROR_CODES = [ + "unsupported", + "too-large", + "too-many", + "inbox-full", + "unreadable", + "expired", + "unavailable", + "invalid-data", +] as const; +export const intakeIdSchema = z.string().uuid(); +const header = { + id: intakeIdSchema, + createdAtMs: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + name: z.string().max(128), +}; +export const intakeItemSchema = z.discriminatedUnion("kind", [ + z + .object({ + ...header, + kind: z.literal("file"), + byteLength: z.number().int().min(1).max(MAX_INTAKE_FILE_BYTES), + format: z.enum(["gpx", "geojson", "kml"]), + }) + .strict(), + z + .object({ + ...header, + kind: z.literal("text"), + byteLength: z.number().int().min(1).max(MAX_INTAKE_TEXT_BYTES), + }) + .strict(), + z + .object({ + ...header, + kind: z.literal("error"), + byteLength: z.literal(0), + errorCode: z.enum(INTAKE_ERROR_CODES), + }) + .strict(), +]); +export type IntakeItem = z.infer; +export type IntakeErrorCode = (typeof INTAKE_ERROR_CODES)[number]; +export const intakeChunkSchema = z + .object({ + id: intakeIdSchema, + offset: z.number().int().nonnegative().max(MAX_INTAKE_FILE_BYTES), + data: z + .string() + .max(32768) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + byteLength: z.number().int().min(0).max(INTAKE_CHUNK_BYTES), + eof: z.boolean(), + }) + .strict(); +export type IntakeChunk = z.infer; +export type SharedTextIntent = + | { kind: "map"; query: string; summary: string } + | { kind: "unsupported"; reason: "unsupported" | "too-large" }; +interface Config { + webOrigin: string; + scheme: string; +} + +function queryIntent(params: URLSearchParams, summary: string): SharedTextIntent { + const query = `?${params.toString()}`; + return query.length <= 2048 + ? { kind: "map", query, summary } + : { kind: "unsupported", reason: "too-large" }; +} +function search(text: string): SharedTextIntent { + return queryIntent(new URLSearchParams({ q: text }), text); +} +function point(text: string, name = ""): SharedTextIntent | null { + const m = + /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)),\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:\(([^\n]*)\))?$/.exec( + text.trim(), + ); + if (!m) return null; + const lat = Number(m[1]); + const lng = Number(m[2]); + if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) + return { kind: "unsupported", reason: "unsupported" }; + const coords = `${lat},${lng}`; + const label = name || m[3] || coords; + return queryIntent( + new URLSearchParams({ + map: `${coords},16,0,0`, + panel: "place", + place: `geo:${coords}`, + at: coords, + name: label, + }), + label, + ); +} +/** Interpret explicit shared data without fetching redirects or selecting an origin. */ +export function classifySharedText(raw: string, config: Config): SharedTextIntent { + if ( + typeof raw !== "string" || + raw.length > MAX_INTAKE_TEXT_LENGTH || + new TextEncoder().encode(raw).byteLength > MAX_INTAKE_TEXT_BYTES + ) + return { kind: "unsupported", reason: "too-large" }; + const text = raw.trim(); + if ( + !text || + Array.from(text).some((c) => c.charCodeAt(0) < 32 && ![9, 10, 13].includes(c.charCodeAt(0))) + ) + return { kind: "unsupported", reason: "unsupported" }; + const direct = point(text); + if (direct) return direct; + const lines = text + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + if (lines.length > 1) { + const labels = lines.filter((s) => !/^[a-z][a-z\d+.-]*:/i.test(s)); + const links = lines.filter((s) => /^[a-z][a-z\d+.-]*:/i.test(s)); + for (const link of links) { + const parsed = classifySharedText(link, config); + if (parsed.kind === "map") return parsed; + } + if (links.length && labels.length) return search(labels.join(" ")); + } + if (!/^[a-z][a-z\d+.-]*:/i.test(text)) return search(text); + let url: URL; + try { + url = new URL(text); + } catch { + return { kind: "unsupported", reason: "unsupported" }; + } + if (url.username || url.password) return { kind: "unsupported", reason: "unsupported" }; + if (url.protocol === "geo:") { + if (url.host || url.hash) return { kind: "unsupported", reason: "unsupported" }; + const [coords, ...attributes] = url.pathname.split(";"); + const base = point(coords); + if ( + !base || + base.kind === "unsupported" || + attributes.some((v) => v.startsWith("crs=") && v !== "crs=wgs84") + ) + return { kind: "unsupported", reason: "unsupported" }; + const q = url.searchParams.get("q")?.trim(); + return q ? (point(q) ?? search(q)) : base; + } + if (url.protocol === "https:") { + const origin = new URL(config.webOrigin).origin; + if (url.origin === origin && url.pathname === "/") + return queryIntent( + url.searchParams, + url.searchParams.get("name") || url.searchParams.get("q") || "OpenMapX", + ); + if (url.port) return { kind: "unsupported", reason: "unsupported" }; + if (url.hostname === "maps.apple.com") { + const q = + url.searchParams.get("query") || + url.searchParams.get("q") || + url.searchParams.get("address"); + const label = url.searchParams.get("name") || q || ""; + return ( + point( + url.searchParams.get("coordinate") || + url.searchParams.get("center") || + url.searchParams.get("ll") || + "", + label, + ) ?? (q ? (point(q) ?? search(q)) : { kind: "unsupported", reason: "unsupported" }) + ); + } + if ( + ["www.google.com", "google.com", "maps.google.com"].includes(url.hostname) && + (url.hostname === "maps.google.com" || + url.pathname === "/maps" || + url.pathname.startsWith("/maps/")) + ) { + const q = url.searchParams.get("query") || url.searchParams.get("q"); + if (q) return point(q) ?? search(q); + const place = /^\/maps\/place\/([^/]+)/.exec(url.pathname); + let name = ""; + try { + if (place) name = decodeURIComponent(place[1].replaceAll("+", " ")); + } catch { + return { kind: "unsupported", reason: "unsupported" }; + } + // The explicit @ segment is a viewport, not necessarily the selected place. + // Retain its label and location without claiming a fetched place identity. + const center = /\/@([^/,]+),([^/,]+)(?:,|\/|$)/.exec(url.pathname); + if (center && (place || url.pathname.startsWith("/maps/@"))) + return ( + point(`${center[1]},${center[2]}`, name) ?? { kind: "unsupported", reason: "unsupported" } + ); + if (name) return search(name); + const m = /^\/maps\/search\/([^/]+)(?:\/@[^/]*)?\/?$/.exec(url.pathname); + if (m) { + try { + const value = decodeURIComponent(m[1]); + return point(value) ?? search(value); + } catch { + /* invalid encoding */ + } + } + } + if (["openstreetmap.org", "www.openstreetmap.org"].includes(url.hostname)) { + const lat = url.searchParams.get("mlat"); + const lng = url.searchParams.get("mlon"); + if (lat !== null && lng !== null) + return point(`${lat},${lng}`) ?? { kind: "unsupported", reason: "unsupported" }; + const m = /^#map=\d+(?:\.\d+)?\/([+-]?[\d.]+)\/([+-]?[\d.]+)$/.exec(url.hash); + if (m) return point(`${m[1]},${m[2]}`) ?? { kind: "unsupported", reason: "unsupported" }; + } + } + if ( + url.protocol === `${config.scheme.toLowerCase()}:` && + !url.host && + (url.pathname === "/" || url.pathname === "") + ) + return queryIntent(url.searchParams, url.searchParams.get("q") || "OpenMapX"); + return { kind: "unsupported", reason: "unsupported" }; +} + +export const intakeListingSchema = z + .object({ + items: z.array(intakeItemSchema).max(10), + canApply: z.boolean(), + scheme: z.string().regex(/^[a-z][a-z0-9+.-]{0,100}$/), + }) + .strict(); diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 06cf8c3cc..a4fb8a587 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -2637,7 +2637,36 @@ "installTitle": "OpenMapX installieren", "iosHintTitle": "Auf iPhone oder iPad installieren", "iosHintDescription": "Tippe auf das Teilen-Symbol und wähle \"Zum Home-Bildschirm\" aus, um OpenMapX zu installieren.", - "gotIt": "Verstanden" + "gotIt": "Verstanden", + "intake": { + "pending": "Geteilte Inhalte ({count})", + "title": "Geteilte Inhalte prüfen", + "item": "Inhalt", + "deferred": "Deine Navigation bleibt aktiv. Diesen Inhalt erst nach ihrem Ende öffnen oder importieren.", + "loading": "Geteilter Inhalt wird gelesen…", + "tooLarge": "Die Grenze beträgt 5 MiB pro Datei oder 4.096 Zeichen pro Ort.", + "tooMany": "Bis zu fünf Geodateien gleichzeitig teilen.", + "inboxFull": "Der Eingang ist voll. Gespeicherte Inhalte öffnen oder verwerfen und erneut teilen.", + "expired": "Dieser Inhalt ist abgelaufen. Bitte erneut teilen.", + "unsupported": "Einen Ort oder eine GPX-, GeoJSON- oder KML-Datei teilen.", + "failed": "Dieser Inhalt konnte nicht gelesen werden. Gespeicherte Inhalte bleiben erhalten. Bitte erneut teilen.", + "search": "Ort oder Adresse", + "editUnsupported": "Adresse oder Koordinaten eingeben. Kurzlinks werden nicht aufgerufen.", + "textHelp": "Den Ort vor dem Öffnen prüfen oder bearbeiten.", + "features": "{format} · {count, plural, one {# Kartenobjekt} other {# Kartenobjekte}}", + "replace": "Der Import ersetzt die aktuelle Ebene „{name}“.", + "retention": "Geteilte Inhalte bleiben bis zu 24 Stunden auf diesem Gerät. Orte werden nicht automatisch gespeichert.", + "discard": "Verwerfen", + "later": "Später", + "replaceAction": "Ebene ersetzen", + "import": "Importieren", + "open": "Öffnen", + "partial": "Einige geteilte Inhalte konnten nicht hinzugefügt werden. Den Fehlereintrag zur Prüfung auswählen. Erfolgreich gespeicherte Inhalte bleiben verfügbar.", + "navigationUnavailable": "OpenMapX konnte den Navigationsstatus nicht prüfen. Schließe und öffne die App vor dem Import erneut. Geteilte Inhalte bleiben erhalten.", + "listingUnavailable": "Geteilte Inhalte sind vorübergehend nicht verfügbar. Versuche es erneut; die Inhalte bleiben erhalten.", + "retry": "Erneut prüfen", + "sharedLocation": "Geteilter Ort" + } }, "offline": { "youreOffline": "Keine Internetverbindung — zwischengespeicherte Daten werden angezeigt", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index d00429509..0fc9d7a92 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -2637,7 +2637,36 @@ "installTitle": "Install OpenMapX", "iosHintTitle": "Install on iPhone or iPad", "iosHintDescription": "Tap the Share button, then choose \"Add to Home Screen\" to install OpenMapX.", - "gotIt": "Got it" + "gotIt": "Got it", + "intake": { + "pending": "Shared items ({count})", + "title": "Review shared content", + "item": "Item", + "deferred": "Your trip stays active. Open or import this after navigation ends.", + "loading": "Reading shared content…", + "tooLarge": "This item exceeds the limit: 5 MiB per file or 4,096 characters per location.", + "tooMany": "Share up to five geographic files at a time.", + "inboxFull": "The inbox is full. Open or discard saved items, then share again.", + "expired": "This item has expired. Share it again.", + "unsupported": "Share a location or a GPX, GeoJSON or KML file.", + "failed": "This item could not be read. Your saved items have been kept; try sharing it again.", + "search": "Location or address", + "editUnsupported": "Enter an address or coordinates. Short links are not followed.", + "textHelp": "Review or edit the location before opening it.", + "features": "{format} · {count, plural, one {# map feature} other {# map features}}", + "replace": "Importing replaces the current overlay “{name}”.", + "retention": "Shared content stays on this device for up to 24 hours. Nothing is added to saved places automatically.", + "discard": "Discard", + "later": "Later", + "replaceAction": "Replace overlay", + "import": "Import", + "open": "Open", + "partial": "Some shared items could not be added. Select an error entry to review it; successfully saved items remain available.", + "navigationUnavailable": "OpenMapX could not check your trip status. Close and reopen the app before importing. Shared items have been kept.", + "listingUnavailable": "Shared items are temporarily unavailable. Try again; your items have been kept.", + "retry": "Check again", + "sharedLocation": "Shared location" + } }, "offline": { "youreOffline": "You're offline — showing cached data", diff --git a/patches/react-native-webview@13.16.1.patch b/patches/react-native-webview@13.16.1.patch index 16cb13c57..a7bcf905a 100644 --- a/patches/react-native-webview@13.16.1.patch +++ b/patches/react-native-webview@13.16.1.patch @@ -18,3 +18,38 @@ index 07f73fd00a083ebf5a03bffaa7bbadbd1f648505..0e50b1997e7775d2846081b7560e5186 grantedPermissions = new ArrayList<>(); ArrayList requestedAndroidPermissions = new ArrayList<>(); +diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java +index 0287e1c56b07b6c23b4049f3992d88b86fdc5c79..14a3371c28d3750bec989c444d8bfdc99815aa1d 100644 +--- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java ++++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java +@@ -74,11 +74,11 @@ public class RNCWebViewClient extends WebViewClient { + public void doUpdateVisitedHistory (WebView webView, String url, boolean isReload) { + super.doUpdateVisitedHistory(webView, url, isReload); + +- ((RNCWebView) webView).dispatchEvent( +- webView, +- new TopLoadingStartEvent( +- RNCWebViewWrapper.getReactTagFromWebView(webView), +- createWebViewEvent(webView, url))); ++ // Preserve navigation/back state without claiming a document committed. ++ WritableMap event = createWebViewEvent(webView, url); ++ event.putBoolean("openmapxDocumentStart", false); ++ ((RNCWebView) webView).dispatchEvent(webView, ++ new TopLoadingStartEvent(RNCWebViewWrapper.getReactTagFromWebView(webView), event)); + } + + @Override +@@ -86,7 +86,13 @@ public class RNCWebViewClient extends WebViewClient { + super.onPageStarted(webView, url, favicon); + mLastLoadFailed = false; + ++ // OpenMapX: only this native callback establishes a new document. Report ++ // it before injection can send a hello; getProgress is not an identity. ++ WritableMap event = createWebViewEvent(webView, url); ++ event.putBoolean("openmapxDocumentStart", true); + RNCWebView reactWebView = (RNCWebView) webView; ++ reactWebView.dispatchEvent(webView, ++ new TopLoadingStartEvent(RNCWebViewWrapper.getReactTagFromWebView(webView), event)); + reactWebView.callInjectedJavaScriptBeforeContentLoaded(); + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db26ff928..dc45190fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,7 +203,7 @@ overrides: patchedDependencies: expo-speech-recognition@57.1.1: b5f9141186cf4462d4af1512b3de5b955ab11bf3296b1ba07aa3979708e623d8 - react-native-webview@13.16.1: c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5 + react-native-webview@13.16.1: d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c importers: @@ -431,49 +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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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)) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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)) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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)) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(258a15991fb2f6b5cd5b08f93c378644) + version: 57.0.21(c4fa2d536fdc5c0144bc66053ce15c37) 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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) expo-splash-screen: specifier: ~57.0.9 - version: 57.0.9(expo@57.0.26(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@19.3.1(react@19.3.1))(react-native-webview@13.16.1(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(supports-color@8.1.1)(typescript@6.0.3) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(patch_hash=c6277f5c642f7b4761ee08cd63a13fd7dadf4e03a2ffa8327793fc8a15b12ba5)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1)(typescript@6.0.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1) + version: 57.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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)) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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)) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -482,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(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) + version: 13.16.1(patch_hash=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.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 @@ -522,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(35cafcd5568d70073f16c8b0222510e8) + version: 57.0.5(ac8200da571dd7cbca5189ddd3eeb997) test-renderer: specifier: ^1.3.1 version: 1.3.1(@types/react@19.3.0)(react@19.3.1) @@ -13670,7 +13670,7 @@ snapshots: optionalDependencies: '@noble/hashes': 2.4.0 - '@expo/cli@57.0.27(772bdb24e39d3df923a6736a4df080eb)': + '@expo/cli@57.0.27(4571b3f24ac978b2ab3a4bd19b70ebd1)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 57.0.9(supports-color@8.1.1)(typescript@6.0.3) @@ -13680,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(a03445a702de6f0994d9fe998e13fee5) + '@expo/log-box': 57.0.4(5efe9f528e4338a28b03d2ec0e17e4a3) '@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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(ae87db08c1472b741ab6581a46da1390) + '@expo/router-server': 57.0.11(09e485154b36a3a768b1c591bb75e782) '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.1(ws@8.22.0) @@ -13706,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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -13802,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(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/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) + expo: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) @@ -13880,16 +13880,16 @@ snapshots: - supports-color - typescript - '@expo/log-box@57.0.4(a03445a702de6f0994d9fe998e13fee5)': + '@expo/log-box@57.0.4(5efe9f528e4338a28b03d2ec0e17e4a3)': 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(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/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) @@ -13915,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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -14005,12 +14005,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@57.0.11(ae87db08c1472b741ab6581a46da1390)': + '@expo/router-server@57.0.11(09e485154b36a3a768b1c591bb75e782)': 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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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: @@ -17260,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(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): + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) @@ -17307,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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -18325,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(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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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): + expo-asset@57.0.18(c4fa2d536fdc5c0144bc66053ce15c37): 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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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@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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) + expo: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -18408,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(258a15991fb2f6b5cd5b08f93c378644): + expo-notifications@57.0.21(c4fa2d536fdc5c0144bc66053ce15c37): 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(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) + expo: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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: @@ -18424,69 +18424,69 @@ snapshots: expo-server@57.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) + ? 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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) + expo: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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): + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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@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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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@57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(772bdb24e39d3df923a6736a4df080eb) + '@expo/cli': 57.0.27(4571b3f24ac978b2ab3a4bd19b70ebd1) '@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(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/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(a03445a702de6f0994d9fe998e13fee5) + '@expo/log-box': 57.0.4(5efe9f528e4338a28b03d2ec0e17e4a3) '@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(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-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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(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) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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(c4fa2d536fdc5c0144bc66053ce15c37) + 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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -18496,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(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-webview: 13.16.1(patch_hash=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.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 @@ -19409,7 +19409,7 @@ snapshots: jest-util: 30.5.1 jest-validate: 30.5.1 - jest-expo@57.0.5(35cafcd5568d70073f16c8b0222510e8): + jest-expo@57.0.5(ac8200da571dd7cbca5189ddd3eeb997): dependencies: '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0(supports-color@8.1.1) @@ -19426,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(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: 57.0.26(@babel/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=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.1)(supports-color@8.1.1))(react@19.3.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.1)(supports-color@8.1.1))(@types/react@19.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 @@ -21250,7 +21250,7 @@ snapshots: react-is@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-webview@13.16.1(patch_hash=d18a3eea765f82199509d6618dbd833b8ea96d26f40c4617576dd4a621b75b0c)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(react@19.3.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/release-notes/native-share-file-intake.md b/release-notes/native-share-file-intake.md new file mode 100644 index 000000000..f55736f47 --- /dev/null +++ b/release-notes/native-share-file-intake.md @@ -0,0 +1,24 @@ +--- +impacts: [operator-action] +--- + +## What changes + +Installed Android and iOS apps receive shared locations and GPX/GeoJSON/KML files +into a bounded local inbox. Users review content before opening a search or +importing a map overlay. Intake waits for native navigation to end. The iOS Share +Extension saves locally and asks the user to open OpenMapX manually. + +## Who is affected + +Native app users and mobile release operators. Browser file import, including +`.json`, remains supported. Protocol-3 navigation remains compatible with newer +web/native builds; native intake requires protocol 4 and rebuilt native apps. + +## Required action + +Deploy the compatible web build before distributing the native update. Register +`.ShareExtension` and `group..intake`, provision the exact App Group +for the host and Share Extension, and regenerate/build with CNG. Do not grant the +navigation widget this group. Qualify Files/mail/cloud-provider delivery on the +intended OS versions and production signing before store distribution.