From b342199455e6111c36d0ca813dc3c6969025dbf5 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 22:46:27 +0100 Subject: [PATCH 01/13] feat(mobile): define bounded intake protocol with v3 compatibility --- apps/mobile/src/bridge/nativeBridge.test.ts | 76 +++++++ apps/mobile/src/bridge/nativeBridge.ts | 38 +++- .../navigation/NavigationCoordinator.test.ts | 22 ++ .../src/navigation/NavigationCoordinator.ts | 5 + apps/web/src/lib/mobile/bridgeClient.test.ts | 37 ++++ apps/web/src/lib/mobile/bridgeClient.ts | 15 +- packages/core/src/navigation/index.ts | 1 + .../core/src/navigation/mobileProtocol.ts | 43 +++- .../core/src/navigation/nativeIntake.test.ts | 76 +++++++ packages/core/src/navigation/nativeIntake.ts | 205 ++++++++++++++++++ 10 files changed, 506 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/navigation/nativeIntake.test.ts create mode 100644 packages/core/src/navigation/nativeIntake.ts diff --git a/apps/mobile/src/bridge/nativeBridge.test.ts b/apps/mobile/src/bridge/nativeBridge.test.ts index 54a7eb172..7677779d4 100644 --- a/apps/mobile/src/bridge/nativeBridge.test.ts +++ b/apps/mobile/src/bridge/nativeBridge.test.ts @@ -573,3 +573,79 @@ describe("NativeBridge.send", () => { expect(bridge.queuedCount).toBe(0); }); }); + +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 intake capability when an older web negotiates version 3", async () => { + const { bridge, registry, injected } = harness({ + describeShell: () => ({ + ...SHELL, + capabilities: { ...SHELL.capabilities, nativeIntake: 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"); + }); +}); + +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 }, + }), + }); + const c = registry.beginDocumentLoad(NOW); + await bridge.receive({ url: WEB_ORIGIN, data: hello(c.nonce) }); + expect(decodePayload(injected[0]).payload).toMatchObject({ + capabilities: { nativeIntake: true }, + selectedProtocolVersion: 4, + }); + }); +}); diff --git a/apps/mobile/src/bridge/nativeBridge.ts b/apps/mobile/src/bridge/nativeBridge.ts index b36bb9c25..bcd4b6513 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; }; permission: "not-determined" | "foreground" | "background" | "denied" | "limited"; locationDriver: "expo" | "native"; @@ -89,7 +91,7 @@ interface SendOptions { } export class NativeBridge { - private queue: NativeToWebMessage[] = []; + private queue: OutboundDraft[] = []; constructor(private readonly deps: NativeBridgeDeps) {} @@ -143,6 +145,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 { @@ -196,11 +207,14 @@ export class NativeBridge { protocolVersion: selected, }); } + const { nativeIntake: _intake, ...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, @@ -234,6 +248,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 && @@ -259,7 +274,13 @@ export class NativeBridge { const validated = nativeToWebSchema.safeParse(candidate); if (!validated.success) return "invalid"; - 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"; @@ -275,7 +296,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"); } @@ -290,10 +311,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/navigation/NavigationCoordinator.test.ts b/apps/mobile/src/navigation/NavigationCoordinator.test.ts index b27dbccab..2b7a21737 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.test.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.test.ts @@ -995,3 +995,25 @@ 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(); + } +}); diff --git a/apps/mobile/src/navigation/NavigationCoordinator.ts b/apps/mobile/src/navigation/NavigationCoordinator.ts index 9e4ee8746..476cb05d5 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.ts @@ -193,6 +193,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/lib/mobile/bridgeClient.test.ts b/apps/web/src/lib/mobile/bridgeClient.test.ts index 699023052..fb9a3da67 100644 --- a/apps/web/src/lib/mobile/bridgeClient.test.ts +++ b/apps/web/src/lib/mobile/bridgeClient.test.ts @@ -503,3 +503,40 @@ 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(); +}); diff --git a/apps/web/src/lib/mobile/bridgeClient.ts b/apps/web/src/lib/mobile/bridgeClient.ts index 91f351e0b..c32b68543 100644 --- a/apps/web/src/lib/mobile/bridgeClient.ts +++ b/apps/web/src/lib/mobile/bridgeClient.ts @@ -201,12 +201,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"); } diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts index 9b67283a4..dcdac8a1f 100644 --- a/packages/core/src/navigation/index.ts +++ b/packages/core/src/navigation/index.ts @@ -129,6 +129,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 05fefc7ec..8aacc9b7c 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", "session.prepare", "session.start", "session.replace", @@ -81,6 +91,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", @@ -328,6 +342,7 @@ export const nativeCapabilitiesSchema = z backgroundLocation: z.boolean(), localNotifications: z.boolean(), speech: z.boolean(), + nativeIntake: z.boolean().optional(), }) .strict(); @@ -359,6 +374,25 @@ const envelope = ( .strict(); 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( "web.hello", z @@ -460,6 +494,13 @@ export const webToNativeSchema = z.discriminatedUnion("type", [ ]); export const nativeToWebSchema = z.discriminatedUnion("type", [ + envelope( + "intake.items", + z.object({ items: z.array(intakeItemSchema).max(10), 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( "native.hello", z diff --git a/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts new file mode 100644 index 000000000..0c0fb1aef --- /dev/null +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -0,0 +1,76 @@ +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"), + ); +}); diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts new file mode 100644 index 000000000..163db6772 --- /dev/null +++ b/packages/core/src/navigation/nativeIntake.ts @@ -0,0 +1,205 @@ +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 link = lines.find((s) => /^https?:\/\//i.test(s)); + if (link && labels.length) { + const parsed = classifySharedText(link, config); + return parsed.kind === "map" ? parsed : 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("q") || url.searchParams.get("address"); + return ( + point(url.searchParams.get("ll") || "", q || "") ?? + (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 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" }; +} From a61fd4c862cb806ca1286147bfa8e4f5f9b12fd7 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 23:04:26 +0100 Subject: [PATCH 02/13] feat(mobile): receive bounded Android shares into a private inbox --- .../openmapx-intake/android/build.gradle | 15 +++ .../android/src/main/AndroidManifest.xml | 1 + .../modules/openmapxintake/IncomingIntent.kt | 18 +++ .../modules/openmapxintake/IntakeInbox.kt | 120 ++++++++++++++++++ .../modules/openmapxintake/IntakeIngress.kt | 86 +++++++++++++ .../modules/openmapxintake/IntakePackage.kt | 20 +++ .../modules/openmapxintake/IntakePolicy.kt | 23 ++++ .../openmapxintake/OpenMapXIntakeModule.kt | 20 +++ .../modules/openmapxintake/ProviderReader.kt | 54 ++++++++ .../openmapxintake/IncomingIntentTest.kt | 31 +++++ .../modules/openmapxintake/IntakeInboxTest.kt | 82 ++++++++++++ .../openmapxintake/ProviderReaderTest.kt | 49 +++++++ .../openmapx-intake/expo-module.config.json | 4 + .../src/intake/nativeIntakeModule.test.ts | 51 ++++++++ apps/mobile/src/intake/nativeIntakeModule.ts | 113 +++++++++++++++++ 15 files changed, 687 insertions(+) create mode 100644 apps/mobile/modules/openmapx-intake/android/build.gradle create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IncomingIntent.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeInbox.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePolicy.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/OpenMapXIntakeModule.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/ProviderReader.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IncomingIntentTest.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeInboxTest.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/ProviderReaderTest.kt create mode 100644 apps/mobile/modules/openmapx-intake/expo-module.config.json create mode 100644 apps/mobile/src/intake/nativeIntakeModule.test.ts create mode 100644 apps/mobile/src/intake/nativeIntakeModule.ts 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..c437cfbab --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IncomingIntent.kt @@ -0,0 +1,18 @@ +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 + } + } + 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..501068575 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt @@ -0,0 +1,86 @@ +package expo.modules.openmapxintake + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +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() + fun inbox(context: Context) = IntakeInbox(File(context.noBackupFilesDir, "openmapx-intake")) + private fun changed() { listeners.forEach { it() } } + 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 { ingest(app, intent) } } + catch (_: Exception) { inbox(app).recordError("inbox-full"); 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); signal.cancel(); try { descriptor.get()?.close() } catch (_: Exception) {} }, IntakePolicy.COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS) + try { + val uris = mutableListOf() + if (intent.action == Intent.ACTION_VIEW) intent.data?.let { uris += it } + else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + val streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM).orEmpty() + if (streams.size > 5) throw IntakeFailure("too-many") + uris += streams + } else intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uris += it } + 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") + 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) { inbox.recordError((e as? IntakeFailure)?.code ?: "unreadable") } + 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/IntakePackage.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt new file mode 100644 index 000000000..e61dbe103 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt @@ -0,0 +1,20 @@ +package expo.modules.openmapxintake + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import expo.modules.core.interfaces.Package +import expo.modules.core.interfaces.ReactActivityLifecycleListener + +class IntakePackage : Package { + override fun createReactActivityLifecycleListeners(activityContext: Context) = listOf(object : ReactActivityLifecycleListener { + private fun receive(activity: Activity?, intent: Intent, state: Bundle?) { + if (!IncomingIntent.shouldCapture(intent, state)) return + IntakeIngress.capture(activityContext, Intent(intent)) + activity?.intent = Intent(Intent.ACTION_MAIN).setPackage(activityContext.packageName) + } + override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { receive(activity, activity.intent, savedInstanceState) } + override fun onNewIntent(intent: Intent): Boolean { receive(activityContext as? Activity, intent, null); return false } + }) +} 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/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..c44fcc015 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IncomingIntentTest.kt @@ -0,0 +1,31 @@ +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 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/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..6d386de51 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android", "apple"], + "android": { "modules": ["expo.modules.openmapxintake.OpenMapXIntakeModule"] } +} 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..bb8d457b4 --- /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) { + 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; + } + }, + }; +} From ad9307f70645c944e7a489192ed0969ad0d459fb Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 23:11:07 +0100 Subject: [PATCH 03/13] feat(mobile): add supported iOS share and document inbox --- .../modules/openmapx-intake/Package.swift | 6 + .../openmapx-intake/expo-module.config.json | 8 +- .../ios/IntakeAppDelegateSubscriber.swift | 39 +++++ .../openmapx-intake/ios/IntakeContainer.swift | 12 ++ .../openmapx-intake/ios/IntakeInbox.swift | 143 ++++++++++++++++++ .../openmapx-intake/ios/IntakePolicy.swift | 21 +++ .../ios/OpenMapXIntake.podspec | 18 +++ .../ios/OpenMapXIntakeModule.swift | 22 +++ .../openmapx-intake/ios/PrivacyInfo.xcprivacy | 10 ++ .../ios/Share/PrivacyInfo.xcprivacy | 10 ++ .../ios/Share/ShareViewController.swift | 92 +++++++++++ .../ios/Tests/IntakeInboxTests.swift | 56 +++++++ 12 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/modules/openmapx-intake/Package.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/IntakePolicy.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/OpenMapXIntake.podspec create mode 100644 apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy create mode 100644 apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy create mode 100644 apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift diff --git a/apps/mobile/modules/openmapx-intake/Package.swift b/apps/mobile/modules/openmapx-intake/Package.swift new file mode 100644 index 000000000..ae3def17e --- /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"]), + .testTarget(name: "IntakeInboxTests", dependencies: ["IntakeInbox"], path: "ios/Tests") +]) diff --git a/apps/mobile/modules/openmapx-intake/expo-module.config.json b/apps/mobile/modules/openmapx-intake/expo-module.config.json index 6d386de51..1ca556436 100644 --- a/apps/mobile/modules/openmapx-intake/expo-module.config.json +++ b/apps/mobile/modules/openmapx-intake/expo-module.config.json @@ -1,4 +1,10 @@ { "platforms": ["android", "apple"], - "android": { "modules": ["expo.modules.openmapxintake.OpenMapXIntakeModule"] } + "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..f1f23fe26 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift @@ -0,0 +1,39 @@ +import ExpoModulesCore +import Foundation +import UIKit + +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) + // Background assertion protects short file-coordination commits against suspension. + let task = application.beginBackgroundTask(withName: "OpenMapX document intake") + Self.queue.async { + do { + let store = try IntakeContainer.store(writer: "host") + do { _ = try store.captureDocument(url, osInbox: inbox, openInPlace: openInPlace) } + catch { try? store.recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") } + NotificationCenter.default.post(name: IntakeContainer.changed, object: nil) + } catch { /* No group authority: never fall back to the app's private files. */ } + DispatchQueue.main.async { application.endBackgroundTask(task) } + } + return true + } + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + // Expo's scene delegate forwards cold URL contexts through application(open:options:). + // Reap abandoned OS copies only after the inbox TTL; never race a fresh cold Open In. + 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..98ec9c1e7 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift @@ -0,0 +1,12 @@ +import Foundation + +/// This group contains only the intake inbox, never credentials or navigation storage. +enum IntakeContainer { + static func store(writer: String) throws -> IntakeInbox { + 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 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/IntakeInbox.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift new file mode 100644 index 000000000..f246dd2b2 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift @@ -0,0 +1,143 @@ +import Foundation + +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(_ work: () throws -> T) throws -> T { + Self.localLock.lock(); defer { Self.localLock.unlock() } + var coordinationError: NSError?, outcome: Result? + NSFileCoordinator().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) + } + func putFile(_ file: URL, name: String, cancelled: () -> Bool = { false }) throws -> IntakeEntry { + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard file.isFileURL, values.isRegularFile == true, values.isSymbolicLink != true, let stream = InputStream(url: file) else { throw IntakeFailure.unreadable } + let display = IntakePolicy.name(name) + return try copy(stream, kind: "file", name: display, format: IntakePolicy.format(display), limit: IntakePolicy.fileBytes, cancelled: cancelled) + } + private func copy(_ input: InputStream, kind: String, name: String, format: String?, limit: Int, cancelled: () -> Bool = { false }) throws -> IntakeEntry { + let started = now(); let deadline = ProcessInfo.processInfo.systemUptime + 10 + let stage = root.appendingPathComponent("staging/\(writer)/\(started)-\(UUID().uuidString)") + try fm.createDirectory(at: stage, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: stage) } + let file = stage.appendingPathComponent("data"); guard fm.createFile(atPath: file.path, contents: nil) else { throw IntakeFailure.unreadable } + let output = try FileHandle(forWritingTo: file); defer { try? output.close() } + input.open(); defer { input.close() } + var size = 0, buffer = [UInt8](repeating: 0, count: 8192) + while true { + guard !cancelled(), ProcessInfo.processInfo.systemUptime < deadline else { throw IntakeFailure.unreadable } + let count = input.read(&buffer, maxLength: buffer.count) + if count < 0 { throw IntakeFailure.unreadable }; 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() + return try coordinated { + 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 }) + size <= IntakePolicy.inboxBytes else { throw IntakeFailure.inboxFull } + let item = IntakeEntry(id: UUID().uuidString.lowercased(), createdAtMs: now(), kind: kind, name: name, byteLength: size, format: format) + try JSONEncoder().encode(item).write(to: stage.appendingPathComponent("metadata.json"), options: .atomic) + try fm.moveItem(at: stage, to: directory(item.id)); return item + } + } + func recordError(_ code: String) throws { try coordinated { + 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") + 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) 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 } + defer { try? fm.removeItem(at: canonical) } + return try putFile(canonical, name: file.lastPathComponent) + } +} 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..4f7a7e312 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift @@ -0,0 +1,22 @@ +import ExpoModulesCore +import Foundation + +public final class OpenMapXIntakeModule: Module { + private var observer: NSObjectProtocol? + private func result(_ work: () throws -> [String: Any]) -> [String: Any] { + 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..50d521604 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + +NSPrivacyTracking +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp +NSPrivacyAccessedAPITypeReasonsC617.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..50d521604 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + +NSPrivacyTracking +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp +NSPrivacyAccessedAPITypeReasonsC617.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..20e76eb7a --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -0,0 +1,92 @@ +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 + private var providers: [NSItemProvider] { (extensionContext?.inputItems as? [NSExtensionItem] ?? []).flatMap { $0.attachments ?? [] } } + private func copy(_ en: String, _ de: String) -> String { self.de ? de : en } + override func viewDidLoad() { + super.viewDidLoad(); view.backgroundColor = .systemBackground + title = copy("Add to OpenMapX", "Zu OpenMapX hinzufügen") + label.numberOfLines = 0; label.textAlignment = .center; label.font = .preferredFont(forTextStyle: .body) + label.text = copy("Add this location or geographic file to OpenMapX. Then open the app to review it.", "Diesen Ort oder diese Geodatei zu OpenMapX hinzufügen. Danach die App zur Prüfung öffnen.") + add.setTitle(copy("Add to OpenMapX", "Zu OpenMapX hinzufügen"), for: .normal) + add.addTarget(self, action: #selector(save), for: .touchUpInside) + done.setTitle(copy("Cancel", "Abbrechen"), for: .normal); done.addTarget(self, action: #selector(finish), for: .touchUpInside) + let heading = UILabel(); heading.text = "OpenMapX"; heading.font = .preferredFont(forTextStyle: .title1) + let stack = UIStackView(arrangedSubviews: [heading, label, add, done]); stack.axis = .vertical; stack.spacing = 24; stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(stack) + NSLayoutConstraint.activate([stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)]) + add.isEnabled = !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.") } + } + @objc private func finish() { + transfers.forEach { $0.cancel() }; progress.forEach { $0.cancel() } + extensionContext?.completeRequest(returningItems: nil) + } + @objc private func save() { + guard !adding else { return }; adding = true; add.isEnabled = false + 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 types = ["com.topografix.gpx", "org.geojson", "com.google.earth.kml", UTType.url.identifier, UTType.plainText.identifier] + // All providers must have a supported representation before saving the first one. + let selections = try items.map { provider -> (NSItemProvider, String) in + guard let type = types.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { throw IntakeFailure.unsupported } + if items.count > 1 && [UTType.url.identifier, UTType.plainText.identifier].contains(type) { throw IntakeFailure.unsupported } + return (provider, type) + } + for (provider, type) in selections { + 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 else { completed(IntakeFailure.unreadable); return } + do { + if let error { throw error } + if let url = item as? URL, url.isFileURL { _ = try store.putFile(url, name: provider.suggestedName ?? url.lastPathComponent, cancelled: { state.cancelled }) } + else if let url = item as? URL { _ = try store.putText(url.absoluteString, cancelled: { state.cancelled }) } + else if let text = item as? String { _ = try store.putText(text, cancelled: { state.cancelled }) } + else { throw IntakeFailure.unsupported } + completed(nil) + } catch { completed(error) } + } + } else { + transfer = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + do { + guard !state.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 == "org.geojson" ? ".geojson" : ".kml" } + _ = try store.putFile(url, name: name, cancelled: { state.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 } + } + } catch { + try? IntakeContainer.store(writer: "extension").recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") + DispatchQueue.main.async { self.label.text = self.copy("Some items could not be added. Open OpenMapX to review saved items.", "Einige Inhalte konnten nicht hinzugefügt werden. Gespeicherte Inhalte in OpenMapX prüfen."); self.done.setTitle(self.copy("Done", "Fertig"), for: .normal) } + return + } + DispatchQueue.main.async { self.label.text = self.copy("Added to OpenMapX. Open the app to view it.", "Zu OpenMapX hinzugefügt. Zum Ansehen die App öffnen."); self.done.setTitle(self.copy("Done", "Fertig"), for: .normal) } + } + } +} 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..4d7d33563 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift @@ -0,0 +1,56 @@ +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)) + } +} From f230f563e55fa03f6971cbd1571b689e272f6e46 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:05:14 +0100 Subject: [PATCH 04/13] feat(mobile): preview native shares and open geographic files safely --- .changeset/native-intake-protocol.md | 6 + apps/mobile/app.config.ts | 4 + apps/mobile/config/mapIntents.test.ts | 16 +- apps/mobile/config/nativeIntakeConfig.ts | 91 +++++ .../modules/openmapxintake/IntakeIngress.kt | 18 +- .../openmapxintake/IntakeIngressTest.kt | 13 + .../openmapx-intake/ios/IntakeInbox.swift | 1 + .../ios/OpenMapXIntakeModule.swift | 5 + .../ios/Share/ShareViewController.swift | 20 +- .../ios/Tests/IntakeInboxTests.swift | 42 +++ apps/mobile/plugins/nativeConfigTransforms.ts | 6 +- apps/mobile/plugins/withNativeIntake.test.ts | 52 +++ apps/mobile/plugins/withNativeIntake.ts | 124 +++++++ .../qa/release/store-review-checklist.md | 2 +- .../qa/results/2026-11-04-native-intake.md | 71 ++++ .../mobile/qa/webview/compatibility-matrix.md | 48 +-- apps/mobile/release/version.json | 4 +- .../scripts/generatedNativeChecks.test.ts | 54 ++- apps/mobile/scripts/generatedNativeChecks.ts | 60 +++- apps/mobile/scripts/readGeneratedNative.ts | 11 + apps/mobile/scripts/run-native-tests.mts | 2 + apps/mobile/src/App.tsx | 80 ++++- .../src/intake/IntakeController.test.ts | 82 +++++ apps/mobile/src/intake/IntakeController.ts | 49 +++ apps/mobile/src/links/deepLinkIntent.test.ts | 2 + apps/web/src/app/layout.tsx | 2 + .../pwa/NativeIntakeHandler.test.tsx | 103 ++++++ .../components/pwa/NativeIntakeHandler.tsx | 338 ++++++++++++++++++ apps/web/src/lib/importGeoFile.ts | 15 +- apps/web/src/lib/mobile/bridgeClient.ts | 4 + apps/web/src/lib/mobile/nativeIntake.test.ts | 111 ++++++ apps/web/src/lib/mobile/nativeIntake.ts | 71 ++++ apps/web/src/lib/mobile/navigationCommands.ts | 9 +- .../src/lib/validateImportedGeometry.test.ts | 53 +++ apps/web/src/lib/validateImportedGeometry.ts | 87 +++++ docs/docs/developer/mobile-architecture.md | 82 ++++- .../docs/developer/mobile-store-operations.md | 17 + .../core/src/navigation/mobileProtocol.ts | 9 +- packages/core/src/navigation/nativeIntake.ts | 8 + packages/i18n/locales/de.json | 27 +- packages/i18n/locales/en.json | 27 +- release-notes/native-share-file-intake.md | 24 ++ 42 files changed, 1780 insertions(+), 70 deletions(-) create mode 100644 .changeset/native-intake-protocol.md create mode 100644 apps/mobile/config/nativeIntakeConfig.ts create mode 100644 apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeIngressTest.kt create mode 100644 apps/mobile/plugins/withNativeIntake.test.ts create mode 100644 apps/mobile/plugins/withNativeIntake.ts create mode 100644 apps/mobile/qa/results/2026-11-04-native-intake.md create mode 100644 apps/mobile/src/intake/IntakeController.test.ts create mode 100644 apps/mobile/src/intake/IntakeController.ts create mode 100644 apps/web/src/components/pwa/NativeIntakeHandler.test.tsx create mode 100644 apps/web/src/components/pwa/NativeIntakeHandler.tsx create mode 100644 apps/web/src/lib/mobile/nativeIntake.test.ts create mode 100644 apps/web/src/lib/mobile/nativeIntake.ts create mode 100644 apps/web/src/lib/validateImportedGeometry.test.ts create mode 100644 apps/web/src/lib/validateImportedGeometry.ts create mode 100644 release-notes/native-share-file-intake.md diff --git a/.changeset/native-intake-protocol.md b/.changeset/native-intake-protocol.md new file mode 100644 index 000000000..2453e64a8 --- /dev/null +++ b/.changeset/native-intake-protocol.md @@ -0,0 +1,6 @@ +--- +"@openmapx/core": minor +--- + +Add bounded native sharing/file-intake contracts and text classification. Extend +the mobile bridge to protocol 4 while preserving protocol-3 navigation overlap. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 49e0e7087..6d79f2513 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,6 +5,7 @@ import type { ExpoConfig } from "expo/config"; // apply type stripping. import { readMobileConfig } from "./config/mobileConfig.ts"; import { IOS_USAGE_DESCRIPTIONS } from "./config/nativeCopy.ts"; +import { intakeAndroidFilters } from "./config/nativeIntakeConfig.ts"; /** * Continuous Native Generation input. @@ -47,6 +48,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, @@ -63,6 +65,7 @@ const config: ExpoConfig = { package: mobile.appId, // A map chooser uses an implicit VIEW intent, independently of App Links. intentFilters: [ + ...intakeAndroidFilters(), { action: "VIEW", category: ["DEFAULT", "BROWSABLE"], @@ -117,6 +120,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..6e2c57d01 --- /dev/null +++ b/apps/mobile/config/nativeIntakeConfig.ts @@ -0,0 +1,91 @@ +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}) OR ${text}).@count == $item.attachments.@count).@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`, + 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", + }; +} 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 index 501068575..e41e9d6dc 100644 --- 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 @@ -21,13 +21,21 @@ 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")) - private fun changed() { listeners.forEach { it() } } + 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 { ingest(app, intent) } } - catch (_: Exception) { inbox(app).recordError("inbox-full"); changed() } + try { workers.execute { try { ingest(app, intent) } catch (_: Exception) { changed() } } } + catch (_: Exception) { + // Rejection runs on the Activity thread; never perform quota/error filesystem work there. + if (overflowPending.compareAndSet(false, true)) overflowWorker.execute { + try { inbox(app).recordError("inbox-full") } catch (_: Exception) {} + finally { overflowPending.set(false); changed() } + } + } } @Suppress("DEPRECATION") private fun ingest(context: Context, intent: Intent) { @@ -36,7 +44,7 @@ object IntakeIngress { val signal = CancellationSignal() val descriptor = AtomicReference(null) val started = System.nanoTime() - val deadline = deadlines.schedule({ cancelled.set(true); signal.cancel(); try { descriptor.get()?.close() } catch (_: Exception) {} }, IntakePolicy.COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS) + 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 = mutableListOf() if (intent.action == Intent.ACTION_VIEW) intent.data?.let { uris += it } @@ -80,7 +88,7 @@ object IntakeIngress { descriptor.set(null) } } - } catch (e: Exception) { inbox.recordError((e as? IntakeFailure)?.code ?: "unreadable") } + } 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/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/ios/IntakeInbox.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift index f246dd2b2..e61099132 100644 --- a/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift @@ -80,6 +80,7 @@ final class IntakeInbox { return try copy(InputStream(data: bytes), kind: "text", name: "Shared location", format: nil, limit: IntakePolicy.textBytes, cancelled: cancelled) } func putFile(_ file: URL, name: String, cancelled: () -> Bool = { false }) throws -> IntakeEntry { + guard file.isFileURL else { throw IntakeFailure.unsupported } let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) guard file.isFileURL, values.isRegularFile == true, values.isSymbolicLink != true, let stream = InputStream(url: file) else { throw IntakeFailure.unreadable } let display = IntakePolicy.name(name) diff --git a/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift index 4f7a7e312..f24152f7c 100644 --- a/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift +++ b/apps/mobile/modules/openmapx-intake/ios/OpenMapXIntakeModule.swift @@ -1,9 +1,14 @@ 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 { diff --git a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift index 20e76eb7a..457b4582f 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -13,6 +13,7 @@ final class ShareViewController: UIViewController { private let label = UILabel(), add = UIButton(type: .system), done = UIButton(type: .system) private var transfers: [TransferState] = [], progress: [Progress] = [] private var adding = false + 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 } override func viewDidLoad() { @@ -31,6 +32,7 @@ final class ShareViewController: UIViewController { 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.") } } @objc private func finish() { + operation.cancel() transfers.forEach { $0.cancel() }; progress.forEach { $0.cancel() } extensionContext?.completeRequest(returningItems: nil) } @@ -41,7 +43,7 @@ final class ShareViewController: UIViewController { DispatchQueue.global(qos: .userInitiated).async { [self] in do { let store = try IntakeContainer.store(writer: "extension") - let types = ["com.topografix.gpx", "org.geojson", "com.google.earth.kml", UTType.url.identifier, UTType.plainText.identifier] + let types = ["com.topografix.gpx", "public.geojson", "com.google.earth.kml", UTType.url.identifier, UTType.plainText.identifier] // All providers must have a supported representation before saving the first one. let selections = try items.map { provider -> (NSItemProvider, String) in guard let type = types.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { throw IntakeFailure.unsupported } @@ -49,6 +51,7 @@ final class ShareViewController: UIViewController { return (provider, type) } 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) } @@ -57,12 +60,12 @@ final class ShareViewController: UIViewController { 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 else { completed(IntakeFailure.unreadable); return } + 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 { _ = try store.putFile(url, name: provider.suggestedName ?? url.lastPathComponent, cancelled: { state.cancelled }) } - else if let url = item as? URL { _ = try store.putText(url.absoluteString, cancelled: { state.cancelled }) } - else if let text = item as? String { _ = try store.putText(text, cancelled: { state.cancelled }) } + if let url = item as? URL, url.isFileURL { throw IntakeFailure.unsupported } + else if let url = item as? URL { _ = try store.putText(url.absoluteString, cancelled: { state.cancelled || self.operation.cancelled }) } + else if let text = item as? String { _ = try store.putText(text, cancelled: { state.cancelled || self.operation.cancelled }) } else { throw IntakeFailure.unsupported } completed(nil) } catch { completed(error) } @@ -70,10 +73,10 @@ final class ShareViewController: UIViewController { } else { transfer = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in do { - guard !state.cancelled, let url, error == nil else { throw IntakeFailure.unreadable } + 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 == "org.geojson" ? ".geojson" : ".kml" } - _ = try store.putFile(url, name: name, cancelled: { state.cancelled }); completed(nil) + 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) } } } @@ -82,6 +85,7 @@ final class ShareViewController: UIViewController { if let failure { throw failure } } } catch { + if operation.cancelled { return } try? IntakeContainer.store(writer: "extension").recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") DispatchQueue.main.async { self.label.text = self.copy("Some items could not be added. Open OpenMapX to review saved items.", "Einige Inhalte konnten nicht hinzugefügt werden. Gespeicherte Inhalte in OpenMapX prüfen."); self.done.setTitle(self.copy("Done", "Fertig"), for: .normal) } return diff --git a/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift index 4d7d33563..ae958d1e1 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/IntakeInboxTests.swift @@ -54,3 +54,45 @@ final class IntakeInboxTests: XCTestCase { 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/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index 46964d086..ec97e74cc 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -66,10 +66,7 @@ export const FORBIDDEN_IOS_ENTITLEMENTS = ["aps-environment"] as const; * Flags that would expose the app container — and therefore the active session's * SQLite file — through the Files app or iTunes sharing. */ -export const FORBIDDEN_IOS_SHARING_KEYS = [ - "UIFileSharingEnabled", - "LSSupportsOpeningDocumentsInPlace", -] as const; +export const FORBIDDEN_IOS_SHARING_KEYS = ["UIFileSharingEnabled"] as const; /** * Data protection for the app container. @@ -84,6 +81,7 @@ export function applyDataProtection(plist: InfoPlistLike): InfoPlistLike { const next: InfoPlistLike = { ...plist, NSFileProtectionKey: IOS_DATA_PROTECTION_CLASS, + LSSupportsOpeningDocumentsInPlace: false, }; 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..aba989657 --- /dev/null +++ b/apps/mobile/plugins/withNativeIntake.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +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"); + 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"); + }); +}); diff --git a/apps/mobile/plugins/withNativeIntake.ts b/apps/mobile/plugins/withNativeIntake.ts new file mode 100644 index 000000000..5c24f3d7c --- /dev/null +++ b/apps/mobile/plugins/withNativeIntake.ts @@ -0,0 +1,124 @@ +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import plistModule from "@expo/plist"; +import { + type ConfigPlugin, + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} from "expo/config-plugins"; +import { + intakeDocumentTypes, + intakeImportedTypes, + intakeShareInfo, + shareBuildSettings, + TARGET, +} from "../config/nativeIntakeConfig.ts"; + +export { + intakeAndroidFilters, + intakeDocumentTypes, + intakeImportedTypes, + 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(), + LSSupportsOpeningDocumentsInPlace: false, + 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", + "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; + }); + return next; +}; +export default withNativeIntake; 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..99d73f83a --- /dev/null +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -0,0 +1,71 @@ +# Native sharing and file intake qualification + +Dates: 2026-11-04–2026-11-05. Development identity `org.openmapx.intake.dev`, scheme +`openmapx-intake-dev`. This records source/build and runtime evidence separately. +It is not store-release acceptance. + +## Automated evidence + +- Full repository verification passed: 2,023 test files passed, 58 skipped; + 24,916 tests passed, 277 skipped. This includes lint, types, policy and OpenAPI + gates. Later focused checks cover the final intake changes. +- Mobile Jest: 70 suites / 1,312 tests passed; later focused shell/intake/link + checks: 4 suites / 110 tests passed. +- Focused web bridge/PWA/parser checks: 21 suites / 258 tests passed. The final + intake/transfer/parser run passed 29 tests, including navigation-start races, + settings-page routing, exact chunks and the transfer deadline. +- SwiftPM inbox: 7 tests passed, covering restart, explicit completion, limits, + expiry, document authority, staging age, concurrent writers, malformed metadata, + actual file size, cancellation and chunk alignment. +- Android inbox/policy/reader tests passed. Robolectric uses temporary files for + `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify + descriptor ownership and deadlines; they do **not** qualify a real provider pipe. +- Android debug APK and iOS Simulator host + navigation/share extension builds + passed. Generated CNG policy, dependency inventory, headless bundle boundary and + documentation build passed. No production provisioning/signing was attempted. + +## Observed runtime behavior + +| Surface | Evidence | Limit | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| Android API 36.1 emulator | OpenMapX appears in the real OS text share sheet. Selecting it copied the owned QA sender's address into the private inbox. | Preview/application, cold receiving, file VIEW, real pipe delivery, Recents and overflow with old web remain unqualified. | +| iOS 27 Simulator | Host and external QA sender installed and launched. | Share Extension Add/manual handoff and cold Files Open In remain unqualified. | +| Browser, 390 × 844 | Actual application preview rendered over the map using a temporary protocol-4 transport fixture. Explicit Import produced the overlay and a completion request. A busy-native fixture disabled Import with the deferred explanation. | This proves web rendering/flow, **not** native receiving or an actual active trip. | + +The Android screenshot in the PR is a real OS share sheet. Preview, imported +geometry and deferred-state screenshots are explicitly labeled browser fixtures. +No image was synthesized or edited. QA senders and the browser transport/proxy +lived outside the repository; there are no shipped test entry points. + +## Device automation failures + +Android's initial snapshot failed while the app was on its splash screen; its +prescribed retry succeeded. A later relaunch failed with “process remained running +after close”; the prescribed debug retry failed with “Android helper could not +launch the application”. Android UI work stopped without an adb UI fallback. + +iOS's tap lost its XCTest transport response and invalidated the runner. The +prescribed startup recovery failed with “Daemon request timed out”. The device +hub screenshot request also failed; device inventory still showed the simulator +booted. iOS UI work stopped without a simctl UI fallback. + +These are evidence limits, not passes. Before release, repeat native OS sharing +and document opening for all three formats on both platforms, cold and warm; +verify active-trip deferral, process-death/Recents replay, full inbox behavior, +foreign pipe-backed attachments and a hung-pipe deadline. Include a MIME/provider +matrix for Files, a browser download, and Drive or Gmail when available. Current +Android registration handles the geographic MIME types and text; generic MIME +fallbacks have not been broadened without provider evidence. + +## Release prerequisites + +Deploy compatible web first; protocol 3 retains navigation and protocol 4 enables +intake. Rebuild native apps. Register/provision `.ShareExtension` and +`group..intake` for host + Share Extension only. The Simulator used local +ad-hoc signing for the group entitlement; this establishes no Apple team, +provisioning profile or App Store validation. The navigation widget has no group. + +Local HTTP was used only for development QA. A release-permissions check correctly +rejected that cleartext development surface. The final HTTPS development surface passed generated and reviewed-permission +checks. Production signing and the final release binary still require their +release gates; local QA is not release transport-policy evidence. 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/generatedNativeChecks.test.ts b/apps/mobile/scripts/generatedNativeChecks.test.ts index 5c362c466..011336e97 100644 --- a/apps/mobile/scripts/generatedNativeChecks.test.ts +++ b/apps/mobile/scripts/generatedNativeChecks.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AndroidManifestLike } from "../plugins/nativeConfigTransforms"; import { REQUIRED_ANDROID_PERMISSIONS } from "../plugins/nativeConfigTransforms"; +import { intakeDocumentTypes, intakeShareInfo } from "../plugins/withNativeIntake"; import { checkGeneratedNativeSurface, type ExpectedNativeSurface, @@ -56,12 +57,20 @@ 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: [{ NSPrivacyAccessedAPITypeReasons: ["C617.1"] }] }, + }, navigationExtension: { NSExtension: { NSExtensionPointIdentifier: "com.apple.widgetkit-extension" }, CFBundleVersion: "$(CURRENT_PROJECT_VERSION)", CFBundleShortVersionString: "$(MARKETING_VERSION)", }, infoPlist: { + LSSupportsOpeningDocumentsInPlace: false, + CFBundleDocumentTypes: intakeDocumentTypes(), + OpenMapXIntakeAppGroup: "group.org.openmapx.app.intake", NSSupportsLiveActivities: true, UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: false, @@ -82,13 +91,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: '', @@ -132,7 +142,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"), ); }); @@ -436,3 +446,43 @@ it("requires the generated ActivityKit support flag", () => { expect.stringContaining("NSSupportsLiveActivities"), ); }); + +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 false and a real extension privacy manifest", () => { + const allowed = surface(); + expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toEqual([]); + allowed.infoPlist.LSSupportsOpeningDocumentsInPlace = true; + expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toContainEqual( + expect.stringContaining("explicitly false"), + ); + 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 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"), + ); +}); diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index 8b89e42b7..9327c3f27 100644 --- a/apps/mobile/scripts/generatedNativeChecks.ts +++ b/apps/mobile/scripts/generatedNativeChecks.ts @@ -35,6 +35,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 +136,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(", ")}`, ); } @@ -202,6 +211,51 @@ function checkIos( if (infoPlist.NSFileProtectionKey !== IOS_DATA_PROTECTION_CLASS) { fail(`the app container must declare ${IOS_DATA_PROTECTION_CLASS}`); } + if (infoPlist.LSSupportsOpeningDocumentsInPlace !== false) + fail("LSSupportsOpeningDocumentsInPlace must be explicitly false"); + 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"); + if (!JSON.stringify(share?.privacy).includes("C617.1")) + fail("share extension privacy manifest must declare its local file access reason"); + 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`); } 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 ea0702f56..bca434120 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", "--no-daemon", ], androidProject, diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 01264ea7b..7cb11b493 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -14,6 +14,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 } from "./intake/nativeIntakeModule"; import { createNavigationRecoveryController } from "./lifecycle/createNavigationRecoveryController"; import { useAppVisibility } from "./lifecycle/useAppLifecycle"; import { buildWebVisibilityScript } from "./lifecycle/webVisibility"; @@ -114,7 +116,9 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement navigatingRef.current = navigating; dispatchShell({ type: navigating ? "session-started" : "session-ended" }); }, []); + const intakePort = useMemo(() => createNativeIntakePort(), []); const { shell, navigation } = useMemo(() => { + let intake: IntakeController; let navigation: Promise = Promise.resolve(null); const composed: ShellBridge = createShellBridge({ webOrigin: config.webOrigin, @@ -123,6 +127,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; + } const composedNavigation = await navigation; if (composedNavigation) { await composedNavigation.coordinator.dispatch(message); @@ -132,7 +140,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, }); @@ -147,8 +158,18 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement onNavigatingChanged: setNavigating, }).catch(() => null); } - return { shell: composed, navigation }; - }, [config, locale, prompter, services, setNavigating]); + 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; + return runtime !== null && (await runtime.activeSession()) === null; + }, + }); + return { shell: composed, navigation, intake }; + }, [config, locale, prompter, services, setNavigating, intakePort]); const deliverLink = useCallback( (intent: DeepLinkIntent) => { shell.bridge.send("deep-link.open", intent); @@ -250,6 +271,56 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement shell.bridge.discardQueue(); }, [shell, connectivity.displayed, incomingLinks]); + 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 @@ -263,11 +334,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) => { diff --git a/apps/mobile/src/intake/IntakeController.test.ts b/apps/mobile/src/intake/IntakeController.test.ts new file mode 100644 index 000000000..14bd8d34e --- /dev/null +++ b/apps/mobile/src/intake/IntakeController.test.ts @@ -0,0 +1,82 @@ +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 controller = new IntakeController({ + port, + currentNonce: () => nonce, + send, + canApply: async () => true, + scheme: "openmapx", + }); + return { + controller, + 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 list and preserves compiled scheme and authority", async () => { + const f = fixture(); + await f.controller.dispatch(message("intake.list") as never); + expect(f.send).toHaveBeenCalledWith("intake.items", { + items: [], + canApply: true, + 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(); +}); diff --git a/apps/mobile/src/intake/IntakeController.ts b/apps/mobile/src/intake/IntakeController.ts new file mode 100644 index 000000000..95ebcd68c --- /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 = await this.deps.canApply(); + 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/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/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c799d7161..ea6588203 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"; @@ -85,6 +86,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..7f2800659 --- /dev/null +++ b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx @@ -0,0 +1,103 @@ +import { useImportedGeometryStore, useNavigationStore } from "@openmapx/core"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +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", () => ({ + NativeIntakeClient: class { + list = f.list; + text = f.text; + complete = f.complete; + }, +})); +const client = { subscribe: () => () => undefined }; +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(); +}); diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.tsx new file mode 100644 index 000000000..9e8ac2ae4 --- /dev/null +++ b/apps/web/src/components/pwa/NativeIntakeHandler.tsx @@ -0,0 +1,338 @@ +"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 [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 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; + 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 { + /* Pending items remain owned natively; foreground or the inbox button can retry. */ + } + }, [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") 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(() => { + if (!visible || !selectedItem || !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, selectedItem, 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 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.canApply || localBlocked()) { + setBlocked(true); + return; + } + if (!current.items.some((item) => item.id === selectedItem.id)) { + setError("expired"); + 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 setError(error instanceof IntakeTransferError ? error.code : "unavailable"); + } finally { + setBusy(false); + } + } + if (!client || !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.name} + + ))} + + )} + {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.ts b/apps/web/src/lib/mobile/bridgeClient.ts index c32b68543..8c1e73c10 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; }; permission: string; activeSession: { sessionId: string; revision: number; kind: "ground" | "transit" } | null; @@ -94,6 +95,9 @@ type RequestOptions = { }; const EXPECTED_REPLY: Partial> = { + "intake.list": "intake.items", + "intake.read": "intake.chunk", + "intake.complete": "intake.completed", "web.hello": "native.hello", "session.prepare": "session.prepared", "session.start": "session.started", 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..6d7094591 --- /dev/null +++ b/apps/web/src/lib/mobile/nativeIntake.test.ts @@ -0,0 +1,111 @@ +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" }); +}); diff --git a/apps/web/src/lib/mobile/nativeIntake.ts b/apps/web/src/lib/mobile/nativeIntake.ts new file mode 100644 index 000000000..dcd6370b8 --- /dev/null +++ b/apps/web/src/lib/mobile/nativeIntake.ts @@ -0,0 +1,71 @@ +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 + : "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/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 4a4994b3a..614ac3511 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -100,9 +100,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. @@ -110,8 +110,11 @@ 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. 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. ### What crosses, and what does not @@ -336,3 +339,74 @@ 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 the main activity. Document VIEW handles `content:` grants, without +BROWSABLE or storage/media permissions. Pre-JS lifecycle capture handles cold and +warm delivery; restored activities and history launches are ignored to prevent +Recents replay. The ordinary map/End Linking path 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. +Normal document Open In accepts regular, non-symlink direct children of the app's +OS-managed `Documents/Inbox`, with opening-in-place explicitly disabled. The source +copy is removed after intake; expired orphan copies are swept on launch. +`UIFileSharingEnabled` stays absent. The app container is not exposed in Files. + +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. 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. + +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. 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. diff --git a/docs/docs/developer/mobile-store-operations.md b/docs/docs/developer/mobile-store-operations.md index 78fdbcf22..1ad0c28df 100644 --- a/docs/docs/developer/mobile-store-operations.md +++ b/docs/docs/developer/mobile-store-operations.md @@ -185,3 +185,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/mobileProtocol.ts b/packages/core/src/navigation/mobileProtocol.ts index 8aacc9b7c..d729bf235 100644 --- a/packages/core/src/navigation/mobileProtocol.ts +++ b/packages/core/src/navigation/mobileProtocol.ts @@ -496,7 +496,14 @@ export const webToNativeSchema = z.discriminatedUnion("type", [ export const nativeToWebSchema = z.discriminatedUnion("type", [ envelope( "intake.items", - z.object({ items: z.array(intakeItemSchema).max(10), forMessageId: boundedId }).strict(), + 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()), diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts index 163db6772..2018269d6 100644 --- a/packages/core/src/navigation/nativeIntake.ts +++ b/packages/core/src/navigation/nativeIntake.ts @@ -203,3 +203,11 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten 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 b4f8de9e7..4fc9c27fc 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -2633,7 +2633,32 @@ "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." + } }, "offline": { "youreOffline": "Keine Internetverbindung — zwischengespeicherte Daten werden angezeigt", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index b435db334..afe32df4e 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -2633,7 +2633,32 @@ "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." + } }, "offline": { "youreOffline": "You're offline — showing cached data", 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. From 6ce9d82e7404ef1ae4554d3251d745bf9497dcaf Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:30:53 +0100 Subject: [PATCH 05/13] fix(mobile): address native intake lifecycle and preview review --- apps/mobile/app.config.ts | 2 - apps/mobile/config/nativeIntakeConfig.ts | 24 +++++- .../modules/openmapx-intake/Package.swift | 2 +- .../modules/openmapxintake/IncomingIntent.kt | 19 +++++ .../modules/openmapxintake/IntakeIngress.kt | 28 +++---- .../modules/openmapxintake/IntakePackage.kt | 20 ----- .../openmapxintake/IntakeReceiverActivity.kt | 46 +++++++++++ .../IntakeReceiverActivityTest.kt | 59 ++++++++++++++ .../ios/Share/ShareViewController.swift | 18 +++-- .../openmapx-intake/ios/ShareSelection.swift | 32 ++++++++ .../ios/Tests/ShareSelectionTests.swift | 30 ++++++++ apps/mobile/plugins/withNativeIntake.test.ts | 9 +++ apps/mobile/plugins/withNativeIntake.ts | 25 ++++++ .../qa/results/2026-11-04-native-intake.md | 28 ++++--- .../scripts/generatedNativeChecks.test.ts | 29 ++++++- apps/mobile/scripts/generatedNativeChecks.ts | 20 +++++ .../pwa/NativeIntakeHandler.test.tsx | 76 ++++++++++++++++++- .../components/pwa/NativeIntakeHandler.tsx | 27 +++++-- docs/docs/developer/mobile-architecture.md | 23 ++++-- .../core/src/navigation/nativeIntake.test.ts | 9 +++ packages/core/src/navigation/nativeIntake.ts | 2 +- packages/i18n/locales/de.json | 3 +- packages/i18n/locales/en.json | 3 +- 23 files changed, 456 insertions(+), 78 deletions(-) delete mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeReceiverActivity.kt create mode 100644 apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeReceiverActivityTest.kt create mode 100644 apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6d79f2513..f030540a6 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,7 +5,6 @@ import type { ExpoConfig } from "expo/config"; // apply type stripping. import { readMobileConfig } from "./config/mobileConfig.ts"; import { IOS_USAGE_DESCRIPTIONS } from "./config/nativeCopy.ts"; -import { intakeAndroidFilters } from "./config/nativeIntakeConfig.ts"; /** * Continuous Native Generation input. @@ -65,7 +64,6 @@ const config: ExpoConfig = { package: mobile.appId, // A map chooser uses an implicit VIEW intent, independently of App Links. intentFilters: [ - ...intakeAndroidFilters(), { action: "VIEW", category: ["DEFAULT", "BROWSABLE"], diff --git a/apps/mobile/config/nativeIntakeConfig.ts b/apps/mobile/config/nativeIntakeConfig.ts index 6e2c57d01..5345faee0 100644 --- a/apps/mobile/config/nativeIntakeConfig.ts +++ b/apps/mobile/config/nativeIntakeConfig.ts @@ -48,7 +48,7 @@ export function intakeShareInfo(appId: string) { ).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}) OR ${text}).@count == $item.attachments.@count).@count == 1`; + 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)", @@ -89,3 +89,25 @@ export function shareBuildSettings( 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 index ae3def17e..d3533e2e1 100644 --- a/apps/mobile/modules/openmapx-intake/Package.swift +++ b/apps/mobile/modules/openmapx-intake/Package.swift @@ -1,6 +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"]), + .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"]), .testTarget(name: "IntakeInboxTests", dependencies: ["IntakeInbox"], path: "ios/Tests") ]) 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 index c437cfbab..544f2e9d7 100644 --- 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 @@ -13,6 +13,25 @@ object IncomingIntent { 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_VIEW) 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/IntakeIngress.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeIngress.kt index e41e9d6dc..d848d5f6d 100644 --- 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 @@ -3,7 +3,6 @@ package expo.modules.openmapxintake import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.OpenableColumns @@ -29,12 +28,14 @@ object IntakeIngress { 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) { - // Rejection runs on the Activity thread; never perform quota/error filesystem work there. - if (overflowPending.compareAndSet(false, true)) overflowWorker.execute { - try { inbox(app).recordError("inbox-full") } catch (_: Exception) {} - finally { overflowPending.set(false); 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") @@ -46,18 +47,7 @@ object IntakeIngress { 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 = mutableListOf() - if (intent.action == Intent.ACTION_VIEW) intent.data?.let { uris += it } - else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - val streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM).orEmpty() - if (streams.size > 5) throw IntakeFailure("too-many") - uris += streams - } else intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uris += it } - 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") + 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") diff --git a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt deleted file mode 100644 index e61dbe103..000000000 --- a/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakePackage.kt +++ /dev/null @@ -1,20 +0,0 @@ -package expo.modules.openmapxintake - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import expo.modules.core.interfaces.Package -import expo.modules.core.interfaces.ReactActivityLifecycleListener - -class IntakePackage : Package { - override fun createReactActivityLifecycleListeners(activityContext: Context) = listOf(object : ReactActivityLifecycleListener { - private fun receive(activity: Activity?, intent: Intent, state: Bundle?) { - if (!IncomingIntent.shouldCapture(intent, state)) return - IntakeIngress.capture(activityContext, Intent(intent)) - activity?.intent = Intent(Intent.ACTION_MAIN).setPackage(activityContext.packageName) - } - override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { receive(activity, activity.intent, savedInstanceState) } - override fun onNewIntent(intent: Intent): Boolean { receive(activityContext as? Activity, intent, null); return false } - }) -} 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..cc253dc38 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/main/java/expo/modules/openmapxintake/IntakeReceiverActivity.kt @@ -0,0 +1,46 @@ +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() } + // Keep the read grants attached to the host task while its background worker copies. + // Never forward the source action, extras, data URI, write or persistable authority. + 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/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..381ad6e97 --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/android/src/test/java/expo/modules/openmapxintake/IntakeReceiverActivityTest.kt @@ -0,0 +1,59 @@ +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() + 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() + val main = shadowOf(receiver).nextStartedActivity + 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/ios/Share/ShareViewController.swift b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift index 457b4582f..30e17fa14 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -43,13 +43,14 @@ final class ShareViewController: UIViewController { DispatchQueue.global(qos: .userInitiated).async { [self] in do { let store = try IntakeContainer.store(writer: "extension") - let types = ["com.topografix.gpx", "public.geojson", "com.google.earth.kml", UTType.url.identifier, UTType.plainText.identifier] - // All providers must have a supported representation before saving the first one. - let selections = try items.map { provider -> (NSItemProvider, String) in - guard let type = types.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { throw IntakeFailure.unsupported } - if items.count > 1 && [UTType.url.identifier, UTType.plainText.identifier].contains(type) { throw IntakeFailure.unsupported } - return (provider, type) + 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) @@ -64,8 +65,8 @@ final class ShareViewController: UIViewController { do { if let error { throw error } if let url = item as? URL, url.isFileURL { throw IntakeFailure.unsupported } - else if let url = item as? URL { _ = try store.putText(url.absoluteString, cancelled: { state.cancelled || self.operation.cancelled }) } - else if let text = item as? String { _ = try store.putText(text, cancelled: { state.cancelled || self.operation.cancelled }) } + else if let url = item as? URL { textValues.append(url.absoluteString); _ = try ShareSelection.text(textValues) } + else if let text = item as? String { textValues.append(text); _ = try ShareSelection.text(textValues) } else { throw IntakeFailure.unsupported } completed(nil) } catch { completed(error) } @@ -84,6 +85,7 @@ final class ShareViewController: UIViewController { 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") 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..a3f28dfe7 --- /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]) 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 !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/ShareSelectionTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift new file mode 100644 index 000000000..d027fa41f --- /dev/null +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift @@ -0,0 +1,30 @@ +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 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/withNativeIntake.test.ts b/apps/mobile/plugins/withNativeIntake.test.ts index aba989657..9ec5252a6 100644 --- a/apps/mobile/plugins/withNativeIntake.test.ts +++ b/apps/mobile/plugins/withNativeIntake.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { intakeReceiverDeclaration } from "../config/nativeIntakeConfig"; import { intakeAndroidFilters, intakeDocumentTypes, @@ -50,3 +51,11 @@ describe("native intake CNG", () => { 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 index 5c24f3d7c..7e8069d32 100644 --- a/apps/mobile/plugins/withNativeIntake.ts +++ b/apps/mobile/plugins/withNativeIntake.ts @@ -2,14 +2,18 @@ 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, @@ -19,6 +23,7 @@ export { intakeAndroidFilters, intakeDocumentTypes, intakeImportedTypes, + intakeReceiverDeclaration, intakeShareInfo, shareBuildSettings, } from "../config/nativeIntakeConfig.ts"; @@ -52,6 +57,7 @@ const withNativeIntake: ConfigPlugin = (config) => { "IntakePolicy.swift", "IntakeInbox.swift", "IntakeContainer.swift", + "ShareSelection.swift", "ShareViewController.swift", ]; for (const source of sources) @@ -119,6 +125,25 @@ const withNativeIntake: ConfigPlugin = (config) => { } 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/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 99d73f83a..ccbe115cc 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -14,10 +14,14 @@ It is not store-release acceptance. - Focused web bridge/PWA/parser checks: 21 suites / 258 tests passed. The final intake/transfer/parser run passed 29 tests, including navigation-start races, settings-page routing, exact chunks and the transfer deadline. -- SwiftPM inbox: 7 tests passed, covering restart, explicit completion, limits, +- SwiftPM inbox and share selection: 11 tests passed, covering restart, explicit completion, limits, expiry, document authority, staging age, concurrent writers, malformed metadata, - actual file size, cancellation and chunk alignment. -- Android inbox/policy/reader tests passed. Robolectric uses temporary files for + actual file size, cancellation, chunk alignment, combined text/URL selection, + mixed-file rejection and Apple’s GeoJSON type. The UTI test runs on the macOS + host; it does not qualify registration on older iOS installations. +- Android inbox/policy/reader/receiver tests: 17 tests passed. The receiver tests + cover Expo’s null-bundle path, neutral host intents, read-only grant forwarding, + explicit repeated shares and thumbnail-only ClipData on text sharing. Robolectric uses temporary files for `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify descriptor ownership and deadlines; they do **not** qualify a real provider pipe. - Android debug APK and iOS Simulator host + navigation/share extension builds @@ -26,13 +30,15 @@ It is not store-release acceptance. ## Observed runtime behavior -| Surface | Evidence | Limit | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| Android API 36.1 emulator | OpenMapX appears in the real OS text share sheet. Selecting it copied the owned QA sender's address into the private inbox. | Preview/application, cold receiving, file VIEW, real pipe delivery, Recents and overflow with old web remain unqualified. | -| iOS 27 Simulator | Host and external QA sender installed and launched. | Share Extension Add/manual handoff and cold Files Open In remain unqualified. | -| Browser, 390 × 844 | Actual application preview rendered over the map using a temporary protocol-4 transport fixture. Explicit Import produced the overlay and a completion request. A busy-native fixture disabled Import with the deferred explanation. | This proves web rendering/flow, **not** native receiving or an actual active trip. | +| Surface | Evidence | Limit | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Android API 36.1 emulator | OpenMapX appears in the real OS text share sheet. Selecting it copied the owned QA sender's address into the private inbox before the review-driven receiver replacement. | Final dedicated receiver delivery, preview/application, cold receiving, file VIEW, real pipes, Recents and old-web overflow remain unqualified. | +| iOS 27 Simulator | Host and external QA sender installed and launched. | Share Extension Add/manual handoff and cold Files Open In remain unqualified. | +| Browser, 390 × 844 | Actual application preview rendered over the map using a temporary protocol-4 transport fixture. Explicit Import produced the overlay and a completion request. A busy-native fixture disabled Import with the deferred explanation. | This proves web rendering/flow, **not** native receiving or an actual active trip. | -The Android screenshot in the PR is a real OS share sheet. Preview, imported +The Android screenshot in the PR is a real OS share sheet from the pre-review +lifecycle-based receiver. The final dedicated receiver preserves registration, +but its delivery has not been visually requalified. Preview, imported geometry and deferred-state screenshots are explicitly labeled browser fixtures. No image was synthesized or edited. QA senders and the browser transport/proxy lived outside the repository; there are no shipped test entry points. @@ -66,6 +72,6 @@ ad-hoc signing for the group entitlement; this establishes no Apple team, provisioning profile or App Store validation. The navigation widget has no group. Local HTTP was used only for development QA. A release-permissions check correctly -rejected that cleartext development surface. The final HTTPS development surface passed generated and reviewed-permission -checks. Production signing and the final release binary still require their +rejected that cleartext development surface. The final HTTPS development surface +passed generated and reviewed-permission checks. Production signing and the final release binary still require their release gates; local QA is not release transport-policy evidence. diff --git a/apps/mobile/scripts/generatedNativeChecks.test.ts b/apps/mobile/scripts/generatedNativeChecks.test.ts index 011336e97..c14426e2d 100644 --- a/apps/mobile/scripts/generatedNativeChecks.test.ts +++ b/apps/mobile/scripts/generatedNativeChecks.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { AndroidManifestLike } from "../plugins/nativeConfigTransforms"; import { REQUIRED_ANDROID_PERMISSIONS } from "../plugins/nativeConfigTransforms"; -import { intakeDocumentTypes, intakeShareInfo } from "../plugins/withNativeIntake"; +import { + intakeDocumentTypes, + intakeReceiverDeclaration, + intakeShareInfo, +} from "../plugins/withNativeIntake"; import { checkGeneratedNativeSurface, type ExpectedNativeSurface, @@ -46,6 +50,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" } }, ], @@ -429,6 +434,7 @@ describe("storage and component exposure", () => { activity?: Array<{ $: Record; "intent-filter"?: unknown[] }>; }; application.activity = [ + ...(application.activity ?? []), { $: { "android:name": ".MainActivity", "android:exported": "true" }, "intent-filter": [{}], @@ -486,3 +492,24 @@ it("rejects broad share activation rules", () => { 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"), + ); +}); diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index 9327c3f27..dc6b449a4 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. * @@ -352,6 +353,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/web/src/components/pwa/NativeIntakeHandler.test.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx index 7f2800659..786a5e13f 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx @@ -1,5 +1,5 @@ import { useImportedGeometryStore, useNavigationStore } from "@openmapx/core"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vitest"; import { NativeIntakeHandler } from "./NativeIntakeHandler"; @@ -31,7 +31,15 @@ vi.mock("@/lib/mobile/nativeIntake", () => ({ complete = f.complete; }, })); -const client = { subscribe: () => () => undefined }; +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; @@ -101,3 +109,67 @@ it("offers a route back to the map before previewing content on settings pages", 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"); + } +}); diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.tsx index 9e8ac2ae4..7be5c9567 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.tsx @@ -65,6 +65,10 @@ export function NativeIntakeHandler() { 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; @@ -96,7 +100,10 @@ export function NativeIntakeHandler() { if (message.type === "intake.changed" || message.type === "session.stopped") void refresh(); }); const foreground = () => { - if (document.visibilityState !== "hidden") void refresh(); + if (document.visibilityState !== "hidden") { + later.current.clear(); + void refresh(); + } }; document.addEventListener("visibilitychange", foreground); return () => { @@ -109,7 +116,15 @@ export function NativeIntakeHandler() { if (navStatus === "idle" && nativeSessionId === null) void refresh(); }, [navStatus, nativeSessionId, refresh]); useEffect(() => { - if (!visible || !selectedItem || !client) return; + const selectedItem = selectedItemRef.current; + if ( + !visible || + !selectedItem || + selectedItem.id !== selectedId || + selectedItem.kind !== selectedKind || + !client + ) + return; const abort = new AbortController(); setDraft(""); setGeometry(null); @@ -138,7 +153,7 @@ export function NativeIntakeHandler() { if (!abort.signal.aborted) setLoading(false); }); return () => abort.abort(); - }, [visible, selectedItem, client]); + }, [visible, selectedId, selectedKind, client]); function postpone() { for (const item of items) later.current.add(item.id); setVisible(false); @@ -254,12 +269,14 @@ export function NativeIntakeHandler() { > {items.map((item) => ( - {item.name} + {item.kind === "text" ? t("sharedLocation") : item.name} ))} )} - {selectedItem?.name} + + {selectedItem?.kind === "text" ? t("sharedLocation") : selectedItem?.name} + {waiting && {t("deferred")}} {items.some((item) => item.kind === "error") && selectedItem?.kind !== "error" && ( {t("partial")} diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 614ac3511..3ca98c3e5 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -343,10 +343,14 @@ 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 the main activity. Document VIEW handles `content:` grants, without -BROWSABLE or storage/media permissions. Pre-JS lifecycle capture handles cold and -warm delivery; restored activities and history launches are ignored to prevent -Recents replay. The ordinary map/End Linking path ignores document URLs. +through a dedicated, nonhistorical receiver activity. Document VIEW handles +`content:` grants, without BROWSABLE or storage/media permissions. The receiver +opens the React task with a neutral MAIN intent and forwards only read grants +while background copying finishes. 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 @@ -375,10 +379,17 @@ background-task assertion. The host module and extension include file-access privacy manifests. 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. Items expire after 24 hours. Names are +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 @@ -410,3 +421,5 @@ 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. diff --git a/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts index 0c0fb1aef..bbd908df6 100644 --- a/packages/core/src/navigation/nativeIntake.test.ts +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -74,3 +74,12 @@ describe("classifySharedText", () => { 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" }); +}); diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts index 2018269d6..e6fe09eb6 100644 --- a/packages/core/src/navigation/nativeIntake.ts +++ b/packages/core/src/navigation/nativeIntake.ts @@ -126,7 +126,7 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten .filter(Boolean); if (lines.length > 1) { const labels = lines.filter((s) => !/^[a-z][a-z\d+.-]*:/i.test(s)); - const link = lines.find((s) => /^https?:\/\//i.test(s)); + const link = lines.find((s) => /^[a-z][a-z\d+.-]*:/i.test(s)); if (link && labels.length) { const parsed = classifySharedText(link, config); return parsed.kind === "map" ? parsed : search(labels.join(" ")); diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 4fc9c27fc..2e34e4317 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -2657,7 +2657,8 @@ "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." + "partial": "Einige geteilte Inhalte konnten nicht hinzugefügt werden. Den Fehlereintrag zur Prüfung auswählen. Erfolgreich gespeicherte Inhalte bleiben verfügbar.", + "sharedLocation": "Geteilter Ort" } }, "offline": { diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index afe32df4e..6e3e75e67 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -2657,7 +2657,8 @@ "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." + "partial": "Some shared items could not be added. Select an error entry to review it; successfully saved items remain available.", + "sharedLocation": "Shared location" } }, "offline": { From 83915b048aecdb79b37fac5d2ade8c443e90f1a3 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:39:50 +0100 Subject: [PATCH 06/13] fix(mobile): keep share grants out of task history --- .../java/expo/modules/openmapxintake/IncomingIntent.kt | 2 +- .../modules/openmapxintake/IntakeReceiverActivity.kt | 10 +++++++--- .../expo/modules/openmapxintake/IncomingIntentTest.kt | 7 +++++++ .../openmapxintake/IntakeReceiverActivityTest.kt | 9 ++++++++- .../ios/Share/ShareViewController.swift | 4 ++-- .../modules/openmapx-intake/ios/ShareSelection.swift | 4 ++-- .../ios/Tests/ShareSelectionTests.swift | 5 +++++ apps/mobile/qa/results/2026-11-04-native-intake.md | 7 ++++--- docs/docs/developer/mobile-architecture.md | 6 ++++-- packages/core/src/navigation/nativeIntake.test.ts | 9 +++++++++ packages/core/src/navigation/nativeIntake.ts | 7 ++++--- 11 files changed, 53 insertions(+), 17 deletions(-) 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 index 544f2e9d7..4f2e28c1a 100644 --- 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 @@ -25,7 +25,7 @@ object IncomingIntent { 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_VIEW) intent.clipData?.let { clip -> + 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 } } 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 index cc253dc38..e15d2d42b 100644 --- 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 @@ -24,9 +24,13 @@ open class IntakeReceiverActivity : Activity() { if (!IncomingIntent.shouldCapture(source, savedState)) return val component = mainComponent() ?: return val grants = try { IncomingIntent.attachments(source).filter { it.scheme == "content" } } catch (_: Exception) { emptyList() } - // Keep the read grants attached to the host task while its background worker copies. - // Never forward the source action, extras, data URI, write or persistable authority. - startActivity(neutralMainIntent(component, grants, source.flags)) + // 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") 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 index c44fcc015..b40782974 100644 --- 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 @@ -22,6 +22,13 @@ class IncomingIntentTest { 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)) 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 index 381ad6e97..ee8ffddbe 100644 --- 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 @@ -17,6 +17,8 @@ import org.robolectric.annotation.Config 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()) } } @@ -33,7 +35,12 @@ class IntakeReceiverActivityTest { 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() - val main = shadowOf(receiver).nextStartedActivity + 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)) diff --git a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift index 30e17fa14..f4f7226d9 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -65,8 +65,8 @@ final class ShareViewController: UIViewController { 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) } - else if let text = item as? String { textValues.append(text); _ = try ShareSelection.text(textValues) } + 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) } diff --git a/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift b/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift index a3f28dfe7..af56334dd 100644 --- a/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift +++ b/apps/mobile/modules/openmapx-intake/ios/ShareSelection.swift @@ -17,7 +17,7 @@ enum ShareSelection: Equatable { guard !text.isEmpty else { throw IntakeFailure.unsupported } return .text(text) } - static func text(_ values: [String]) throws -> String { + 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 }) { @@ -25,7 +25,7 @@ enum ShareSelection: Equatable { else if !labels.contains(line) { labels.append(line) } } let combined = (labels + (link.map { [$0] } ?? [])).joined(separator: "\n") - guard !combined.isEmpty else { throw IntakeFailure.unsupported } + 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/ShareSelectionTests.swift b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift index d027fa41f..e29260882 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Tests/ShareSelectionTests.swift @@ -11,6 +11,11 @@ final class ShareSelectionTests: XCTestCase { 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))) diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index ccbe115cc..2029982a6 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -14,14 +14,15 @@ It is not store-release acceptance. - Focused web bridge/PWA/parser checks: 21 suites / 258 tests passed. The final intake/transfer/parser run passed 29 tests, including navigation-start races, settings-page routing, exact chunks and the transfer deadline. -- SwiftPM inbox and share selection: 11 tests passed, covering restart, explicit completion, limits, +- SwiftPM inbox and share selection: 12 tests passed, covering restart, explicit completion, limits, expiry, document authority, staging age, concurrent writers, malformed metadata, actual file size, cancellation, chunk alignment, combined text/URL selection, mixed-file rejection and Apple’s GeoJSON type. The UTI test runs on the macOS host; it does not qualify registration on older iOS installations. -- Android inbox/policy/reader/receiver tests: 17 tests passed. The receiver tests +- Android inbox/policy/reader/receiver tests: 18 tests passed. The receiver tests cover Expo’s null-bundle path, neutral host intents, read-only grant forwarding, - explicit repeated shares and thumbnail-only ClipData on text sharing. Robolectric uses temporary files for + explicit repeated shares, clean root intents before grant forwarding and preview + thumbnails on both text and single-file sharing. Robolectric uses temporary files for `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify descriptor ownership and deadlines; they do **not** qualify a real provider pipe. - Android debug APK and iOS Simulator host + navigation/share extension builds diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 3ca98c3e5..f509bd82c 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -345,8 +345,10 @@ physical-device background/force-quit behavior or App Store signing acceptance. 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 with a neutral MAIN intent and forwards only read grants -while background copying finishes. The task never retains the original share +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 diff --git a/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts index bbd908df6..72e1028d2 100644 --- a/packages/core/src/navigation/nativeIntake.test.ts +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -83,3 +83,12 @@ it("retains explicit geo coordinates when a sender also provides a location labe }), ).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"); +}); diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts index e6fe09eb6..1775d09af 100644 --- a/packages/core/src/navigation/nativeIntake.ts +++ b/packages/core/src/navigation/nativeIntake.ts @@ -126,11 +126,12 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten .filter(Boolean); if (lines.length > 1) { const labels = lines.filter((s) => !/^[a-z][a-z\d+.-]*:/i.test(s)); - const link = lines.find((s) => /^[a-z][a-z\d+.-]*:/i.test(s)); - if (link && labels.length) { + const links = lines.filter((s) => /^[a-z][a-z\d+.-]*:/i.test(s)); + for (const link of links) { const parsed = classifySharedText(link, config); - return parsed.kind === "map" ? parsed : search(labels.join(" ")); + 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; From 0774a8538e135efc0a44efd608f9474325826195 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:42:03 +0100 Subject: [PATCH 07/13] docs(mobile): record final intake review verification --- .../qa/results/2026-11-04-native-intake.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 2029982a6..b291134db 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -9,21 +9,21 @@ It is not store-release acceptance. - Full repository verification passed: 2,023 test files passed, 58 skipped; 24,916 tests passed, 277 skipped. This includes lint, types, policy and OpenAPI gates. Later focused checks cover the final intake changes. -- Mobile Jest: 70 suites / 1,312 tests passed; later focused shell/intake/link - checks: 4 suites / 110 tests passed. -- Focused web bridge/PWA/parser checks: 21 suites / 258 tests passed. The final - intake/transfer/parser run passed 29 tests, including navigation-start races, - settings-page routing, exact chunks and the transfer deadline. -- SwiftPM inbox and share selection: 12 tests passed, covering restart, explicit completion, limits, - expiry, document authority, staging age, concurrent writers, malformed metadata, +- Final mobile Jest: 70 suites / 1,314 tests passed. +- Earlier web bridge/PWA/parser checks: 21 suites / 258 tests passed. After review, + 3 focused preview/transfer/parser suites passed 24 tests, including refresh-stable + drafts, navigation-start races, settings routing, chunks and the deadline. +- Final classifier/generated-native policy checks: 3 suites / 72 tests passed. +- SwiftPM inbox and share selection: 12 tests passed, covering restart, explicit + completion, limits, expiry, document authority, staging age, concurrent writers, malformed metadata, actual file size, cancellation, chunk alignment, combined text/URL selection, mixed-file rejection and Apple’s GeoJSON type. The UTI test runs on the macOS host; it does not qualify registration on older iOS installations. - Android inbox/policy/reader/receiver tests: 18 tests passed. The receiver tests cover Expo’s null-bundle path, neutral host intents, read-only grant forwarding, explicit repeated shares, clean root intents before grant forwarding and preview - thumbnails on both text and single-file sharing. Robolectric uses temporary files for - `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify + thumbnails on both text and single-file sharing. Robolectric uses temporary + files for `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify descriptor ownership and deadlines; they do **not** qualify a real provider pipe. - Android debug APK and iOS Simulator host + navigation/share extension builds passed. Generated CNG policy, dependency inventory, headless bundle boundary and @@ -74,5 +74,5 @@ provisioning profile or App Store validation. The navigation widget has no group Local HTTP was used only for development QA. A release-permissions check correctly rejected that cleartext development surface. The final HTTPS development surface -passed generated and reviewed-permission checks. Production signing and the final release binary still require their -release gates; local QA is not release transport-policy evidence. +passed generated and reviewed-permission checks. Production signing and the +final release binary still require their release gates; local QA is not release transport-policy evidence. From dade64ab5f8cbb232590e42095827cb67384d52b Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 00:42:37 +0100 Subject: [PATCH 08/13] fix(release): qualify intake changeset for public CLI --- .changeset/native-intake-protocol.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/native-intake-protocol.md b/.changeset/native-intake-protocol.md index 2453e64a8..7919534be 100644 --- a/.changeset/native-intake-protocol.md +++ b/.changeset/native-intake-protocol.md @@ -1,6 +1,6 @@ --- -"@openmapx/core": minor +"@openmapx/extension-cli": patch --- -Add bounded native sharing/file-intake contracts and text classification. Extend -the mobile bridge to protocol 4 while preserving protocol-3 navigation overlap. +Rebuild the public CLI bundle after shared core native-intake contracts and bridge +version updates. Extension commands retain their existing behavior. From 2c6fc438643ab4057ccfa07286e6c2fa99d44e11 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 03:54:59 +0100 Subject: [PATCH 09/13] fix(mobile): qualify document lifecycle and scoped file intake --- .../modules/openmapx-intake/Package.swift | 2 +- .../ios/IntakeAppDelegateSubscriber.swift | 61 ++++- .../openmapx-intake/ios/IntakeContainer.swift | 7 +- .../ios/IntakeExternalDocument.swift | 156 +++++++++++++ .../openmapx-intake/ios/IntakeInbox.swift | 102 +++++++-- .../openmapx-intake/ios/PrivacyInfo.xcprivacy | 5 +- .../ios/Share/PrivacyInfo.xcprivacy | 5 +- .../Tests/IntakeExternalDocumentTests.swift | 142 ++++++++++++ apps/mobile/plugins/nativeConfigTransforms.ts | 7 +- apps/mobile/plugins/withNativeIntake.ts | 1 - .../plugins/withOpenMapXNativeConfig.test.ts | 7 + .../qa/results/2026-11-04-native-intake.md | 91 +++----- .../scripts/assert-no-community-runtime.mts | 11 + .../scripts/clean-prebuild-check.test.ts | 11 + .../scripts/generatedNativeChecks.test.ts | 38 +++- apps/mobile/scripts/generatedNativeChecks.ts | 25 +- .../scripts/normalizeGeneratedNative.ts | 9 +- .../scripts/webViewDocumentPolicy.test.ts | 26 +++ apps/mobile/scripts/webViewDocumentPolicy.ts | 21 ++ apps/mobile/src/App.test.tsx | 215 ++++++++++++++++-- apps/mobile/src/App.tsx | 39 +++- apps/web/src/lib/mobile/bridgeClient.test.ts | 33 ++- apps/web/src/lib/mobile/bridgeClient.ts | 19 +- docs/docs/developer/mobile-architecture.md | 33 ++- patches/react-native-webview@13.16.1.patch | 35 +++ pnpm-lock.yaml | 172 +++++++------- 26 files changed, 1037 insertions(+), 236 deletions(-) create mode 100644 apps/mobile/modules/openmapx-intake/ios/IntakeExternalDocument.swift create mode 100644 apps/mobile/modules/openmapx-intake/ios/Tests/IntakeExternalDocumentTests.swift create mode 100644 apps/mobile/scripts/webViewDocumentPolicy.test.ts create mode 100644 apps/mobile/scripts/webViewDocumentPolicy.ts diff --git a/apps/mobile/modules/openmapx-intake/Package.swift b/apps/mobile/modules/openmapx-intake/Package.swift index d3533e2e1..c2f195cba 100644 --- a/apps/mobile/modules/openmapx-intake/Package.swift +++ b/apps/mobile/modules/openmapx-intake/Package.swift @@ -1,6 +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"]), + .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/ios/IntakeAppDelegateSubscriber.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift index f1f23fe26..686b72e3e 100644 --- a/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift @@ -2,6 +2,26 @@ 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 { @@ -9,22 +29,47 @@ public final class IntakeAppDelegateSubscriber: ExpoAppDelegateSubscriber { 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) - // Background assertion protects short file-coordination commits against suspension. - let task = application.beginBackgroundTask(withName: "OpenMapX document intake") + 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 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") - do { _ = try store.captureDocument(url, osInbox: inbox, openInPlace: openInPlace) } - catch { try? store.recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") } - NotificationCenter.default.post(name: IntakeContainer.changed, object: nil) + 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. */ } - DispatchQueue.main.async { application.endBackgroundTask(task) } } return true } public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - // Expo's scene delegate forwards cold URL contexts through application(open:options:). - // Reap abandoned OS copies only after the inbox TTL; never race a fresh cold Open In. + // 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") diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift index 98ec9c1e7..b0d421e4b 100644 --- a/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeContainer.swift @@ -2,11 +2,14 @@ import Foundation /// This group contains only the intake inbox, never credentials or navigation storage. enum IntakeContainer { - static func store(writer: String) throws -> IntakeInbox { + 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 try IntakeInbox(root: root.appendingPathComponent("Inbox", isDirectory: true), writer: writer) + 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 index e61099132..f480e2475 100644 --- a/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeInbox.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin struct IntakeEntry: Codable { let id: String, createdAtMs: Int64, kind: String, name: String, byteLength: Int @@ -27,10 +28,11 @@ final class IntakeInbox { try fm.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: self.root.path) #endif } - private func coordinated(_ work: () throws -> T) throws -> T { + private func coordinated(onCoordinator: ((NSFileCoordinator) -> Void)? = nil, _ work: () throws -> T) throws -> T { Self.localLock.lock(); defer { Self.localLock.unlock() } var coordinationError: NSError?, outcome: Result? - NSFileCoordinator().coordinate(writingItemAt: root, options: .forMerging, error: &coordinationError) { _ in outcome = Result { try work() } } + 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() } @@ -79,44 +81,91 @@ final class IntakeInbox { 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) } - func putFile(_ file: URL, name: String, cancelled: () -> Bool = { false }) throws -> IntakeEntry { - guard file.isFileURL else { throw IntakeFailure.unsupported } - let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) - guard file.isFileURL, values.isRegularFile == true, values.isSymbolicLink != true, let stream = InputStream(url: file) else { throw IntakeFailure.unreadable } - let display = IntakePolicy.name(name) - return try copy(stream, kind: "file", name: display, format: IntakePolicy.format(display), limit: IntakePolicy.fileBytes, 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 started = now(); let deadline = ProcessInfo.processInfo.systemUptime + 10 - let stage = root.appendingPathComponent("staging/\(writer)/\(started)-\(UUID().uuidString)") - try fm.createDirectory(at: stage, withIntermediateDirectories: true) - defer { try? fm.removeItem(at: stage) } - let file = stage.appendingPathComponent("data"); guard fm.createFile(atPath: file.path, contents: nil) else { throw IntakeFailure.unreadable } - let output = try FileHandle(forWritingTo: file); defer { try? output.close() } + 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 count = input.read(&buffer, maxLength: buffer.count) - if count < 0 { throw IntakeFailure.unreadable }; if count == 0 { break } + 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() - return try coordinated { + 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 }) + size <= IntakePolicy.inboxBytes else { throw IntakeFailure.inboxFull } - let item = IntakeEntry(id: UUID().uuidString.lowercased(), createdAtMs: now(), kind: kind, name: name, byteLength: size, format: format) - try JSONEncoder().encode(item).write(to: stage.appendingPathComponent("metadata.json"), options: .atomic) - try fm.moveItem(at: stage, to: directory(item.id)); return item + 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) throws { try coordinated { + 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) } } @@ -133,12 +182,17 @@ final class IntakeInbox { 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) throws -> IntakeEntry { + 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) + return try putFile(canonical, name: file.lastPathComponent, deadline: deadline, cancelled: cancelled, onCoordinator: onCoordinator) } } diff --git a/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy b/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy index 50d521604..143d4cf43 100644 --- a/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy +++ b/apps/mobile/modules/openmapx-intake/ios/PrivacyInfo.xcprivacy @@ -5,6 +5,9 @@ NSPrivacyCollectedDataTypes NSPrivacyAccessedAPITypes NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp -NSPrivacyAccessedAPITypeReasonsC617.1 +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 index 50d521604..143d4cf43 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy +++ b/apps/mobile/modules/openmapx-intake/ios/Share/PrivacyInfo.xcprivacy @@ -5,6 +5,9 @@ NSPrivacyCollectedDataTypes NSPrivacyAccessedAPITypes NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp -NSPrivacyAccessedAPITypeReasonsC617.1 +NSPrivacyAccessedAPITypeReasonsC617.13B52.1 + +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategorySystemBootTime +NSPrivacyAccessedAPITypeReasons35F9.1 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/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index ccb4b35bf..be4d9250f 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -69,7 +69,10 @@ export const FORBIDDEN_IOS_ENTITLEMENTS = ["aps-environment"] as const; * Flags that would expose the app container — and therefore the active session's * SQLite file — through the Files app or iTunes sharing. */ -export const FORBIDDEN_IOS_SHARING_KEYS = ["UIFileSharingEnabled"] as const; +export const FORBIDDEN_IOS_SHARING_KEYS = [ + "UIFileSharingEnabled", + "UISupportsDocumentBrowser", +] as const; /** * Data protection for the app container. @@ -84,7 +87,7 @@ export function applyDataProtection(plist: InfoPlistLike): InfoPlistLike { const next: InfoPlistLike = { ...plist, NSFileProtectionKey: IOS_DATA_PROTECTION_CLASS, - LSSupportsOpeningDocumentsInPlace: false, + LSSupportsOpeningDocumentsInPlace: true, }; for (const key of FORBIDDEN_IOS_SHARING_KEYS) delete next[key]; return next; diff --git a/apps/mobile/plugins/withNativeIntake.ts b/apps/mobile/plugins/withNativeIntake.ts index 7e8069d32..e5edfac5f 100644 --- a/apps/mobile/plugins/withNativeIntake.ts +++ b/apps/mobile/plugins/withNativeIntake.ts @@ -39,7 +39,6 @@ const withNativeIntake: ConfigPlugin = (config) => { Object.assign(mod.modResults, { CFBundleDocumentTypes: intakeDocumentTypes(), UTImportedTypeDeclarations: intakeImportedTypes(), - LSSupportsOpeningDocumentsInPlace: false, OpenMapXIntakeAppGroup: group, }); return mod; 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/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 497cbf564..501a7842e 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -1,82 +1,43 @@ # Native sharing and file intake qualification -Dates: 2026-11-04–2026-11-05. Development identity `org.openmapx.intake.dev`, scheme -`openmapx-intake-dev`. This records source/build and runtime evidence separately. -It is not store-release acceptance. +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 -- Full repository verification passed: 2,023 test files passed, 58 skipped; - 24,916 tests passed, 277 skipped. This includes lint, types, policy and OpenAPI - gates. Later focused checks cover the final intake changes. -- After merging Android Auto from main, mobile Jest: 80 suites / 1,344 tests passed. -- Earlier web bridge/PWA/parser checks: 21 suites / 258 tests passed. After review, - 3 focused preview/transfer/parser suites passed 24 tests, including refresh-stable - drafts, navigation-start races, settings routing, chunks and the deadline. -- After merging Android Auto, core/config checks: 6 suites / 160 tests passed; - web bridge/intake/car checks: 6 suites / 74 tests passed. The shell advertises - both new capabilities to protocol-4 peers and omits them from older strict - protocol-3 hello replies. The intake UI stays outside the car surface. -- SwiftPM inbox and share selection: 12 tests passed, covering restart, explicit - completion, limits, expiry, document authority, staging age, concurrent writers, malformed metadata, - actual file size, cancellation, chunk alignment, combined text/URL selection, - mixed-file rejection and Apple’s GeoJSON type. The UTI test runs on the macOS - host; it does not qualify registration on older iOS installations. -- Android inbox/policy/reader/receiver tests: 18 tests passed. The receiver tests - cover Expo’s null-bundle path, neutral host intents, read-only grant forwarding, - explicit repeated shares, clean root intents before grant forwarding and preview - thumbnails on both text and single-file sharing. Robolectric uses temporary - files for `createPipe` and does not implement `Os.poll/read`: the IO adapter tests verify - descriptor ownership and deadlines; they do **not** qualify a real provider pipe. -- After merging Android Auto, the regenerated Android APK and all four native - JVM suites passed (67 tests total). iOS Simulator host + navigation/share extension builds - passed. Generated CNG policy, dependency inventory, headless bundle boundary and - documentation build passed. No production provisioning/signing was attempted. +- 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 -| Surface | Evidence | Limit | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| Android API 36.1 emulator | OpenMapX appears in the real OS text share sheet. Selecting it copied the owned QA sender's address into the private inbox before the review-driven receiver replacement. | Final dedicated receiver delivery, preview/application, cold receiving, file VIEW, real pipes, Recents and old-web overflow remain unqualified. | -| iOS 27 Simulator | Host and external QA sender installed and launched. | Share Extension Add/manual handoff and cold Files Open In remain unqualified. | -| Browser, 390 × 844 | Actual application preview rendered over the map using a temporary protocol-4 transport fixture. Explicit Import produced the overlay and a completion request. A busy-native fixture disabled Import with the deferred explanation. | This proves web rendering/flow, **not** native receiving or an actual active trip. | +| 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. | -The Android screenshot in the PR is a real OS share sheet from the pre-review -lifecycle-based receiver. The final dedicated receiver preserves registration, -but its delivery has not been visually requalified. Preview, imported -geometry and deferred-state screenshots are explicitly labeled browser fixtures. -No image was synthesized or edited. QA senders and the browser transport/proxy -lived outside the repository; there are no shipped test entry points. +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. -## Device automation failures +## Runtime corrections and limits -Android's initial snapshot failed while the app was on its splash screen; its -prescribed retry succeeded. A later relaunch failed with “process remained running -after close”; the prescribed debug retry failed with “Android helper could not -launch the application”. Android UI work stopped without an adb UI fallback. +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. -iOS's tap lost its XCTest transport response and invalidated the runner. The -prescribed startup recovery failed with “Daemon request timed out”. The device -hub screenshot request also failed; device inventory still showed the simulator -booted. iOS UI work stopped without a simctl UI fallback. +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. -These are evidence limits, not passes. Before release, repeat native OS sharing -and document opening for all three formats on both platforms, cold and warm; -verify active-trip deferral, process-death/Recents replay, full inbox behavior, -foreign pipe-backed attachments and a hung-pipe deadline. Include a MIME/provider -matrix for Files, a browser download, and Drive or Gmail when available. Current -Android registration handles the geographic MIME types and text; generic MIME -fallbacks have not been broadened without provider evidence. +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 -Deploy compatible web first; protocol 3 retains navigation and protocol 4 enables -intake. Rebuild native apps. Register/provision `.ShareExtension` and -`group..intake` for host + Share Extension only. The Simulator used local -ad-hoc signing for the group entitlement; this establishes no Apple team, -provisioning profile or App Store validation. The navigation widget has no group. +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 QA. A release-permissions check correctly -rejected that cleartext development surface. The final HTTPS development surface -passed generated and reviewed-permission checks. Production signing and the -final release binary still require their release gates; local QA is not release transport-policy evidence. +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. 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 0037a55e0..ac3fd0a55 100644 --- a/apps/mobile/scripts/generatedNativeChecks.test.ts +++ b/apps/mobile/scripts/generatedNativeChecks.test.ts @@ -73,7 +73,18 @@ function surface(): GeneratedNativeSurface { shareExtension: { infoPlist: intakeShareInfo("org.openmapx.app"), entitlements: { "com.apple.security.application-groups": ["group.org.openmapx.app.intake"] }, - privacy: { NSPrivacyAccessedAPITypes: [{ NSPrivacyAccessedAPITypeReasons: ["C617.1"] }] }, + privacy: { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp", + NSPrivacyAccessedAPITypeReasons: ["C617.1", "3B52.1"], + }, + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategorySystemBootTime", + NSPrivacyAccessedAPITypeReasons: ["35F9.1"], + }, + ], + }, }, navigationExtension: { NSExtension: { NSExtensionPointIdentifier: "com.apple.widgetkit-extension" }, @@ -81,7 +92,7 @@ function surface(): GeneratedNativeSurface { CFBundleShortVersionString: "$(MARKETING_VERSION)", }, infoPlist: { - LSSupportsOpeningDocumentsInPlace: false, + LSSupportsOpeningDocumentsInPlace: true, CFBundleDocumentTypes: intakeDocumentTypes(), OpenMapXIntakeAppGroup: "group.org.openmapx.app.intake", NSMicrophoneUsageDescription: @@ -390,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( @@ -479,12 +490,12 @@ it("rejects intake groups leaking to the navigation widget or broad extension en expect.stringContaining("exactly the intake App Group"), ); }); -it("requires explicit opening-in-place false and a real extension privacy manifest", () => { +it("requires explicit opening-in-place true and a real extension privacy manifest", () => { const allowed = surface(); expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toEqual([]); - allowed.infoPlist.LSSupportsOpeningDocumentsInPlace = true; + allowed.infoPlist.LSSupportsOpeningDocumentsInPlace = false; expect(checkGeneratedNativeSurface(allowed, EXPECTED)).toContainEqual( - expect.stringContaining("explicitly false"), + expect.stringContaining("explicitly true"), ); const missing = surface(); if (!missing.shareExtension) throw new Error("missing fixture"); @@ -493,6 +504,21 @@ it("requires explicit opening-in-place false and a real extension privacy manife 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"); diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index 02600ec43..21fc3a06f 100644 --- a/apps/mobile/scripts/generatedNativeChecks.ts +++ b/apps/mobile/scripts/generatedNativeChecks.ts @@ -214,8 +214,8 @@ function checkIos( if (infoPlist.NSFileProtectionKey !== IOS_DATA_PROTECTION_CLASS) { fail(`the app container must declare ${IOS_DATA_PROTECTION_CLASS}`); } - if (infoPlist.LSSupportsOpeningDocumentsInPlace !== false) - fail("LSSupportsOpeningDocumentsInPlace must be explicitly false"); + if (infoPlist.LSSupportsOpeningDocumentsInPlace !== true) + fail("LSSupportsOpeningDocumentsInPlace must be explicitly true"); const group = `group.${expected.appId}.intake`; const share = generated.shareExtension; if ( @@ -243,8 +243,25 @@ function checkIos( !generated.pbxproj.includes("OpenMapXShareExtension/ShareExtension.entitlements") ) fail("share extension must be embedded with its own entitlement file"); - if (!JSON.stringify(share?.privacy).includes("C617.1")) - fail("share extension privacy manifest must declare its local file access reason"); + 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`) && 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/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 52ce2853a..35d936d82 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -4,7 +4,7 @@ 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"; @@ -121,7 +121,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 () => { @@ -254,7 +254,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 () => { @@ -311,9 +311,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: { @@ -323,20 +326,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 () => { @@ -412,7 +600,7 @@ describe("App bridge channel", () => { 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, @@ -647,12 +835,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 4cc281dd0..22aa6a15f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -116,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)); @@ -301,20 +302,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 @@ -329,6 +343,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)); @@ -338,11 +356,14 @@ 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(""); @@ -518,7 +539,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement { + 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 33a741391..1112b17fc 100644 --- a/apps/web/src/lib/mobile/bridgeClient.ts +++ b/apps/web/src/lib/mobile/bridgeClient.ts @@ -132,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; @@ -173,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(); @@ -267,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); @@ -277,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")); } }); @@ -340,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/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 786685cbf..a627fca30 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -419,17 +419,40 @@ On iOS, `OpenMapXShareExtension` saves user-confirmed items into 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. -Normal document Open In accepts regular, non-symlink direct children of the app's -OS-managed `Documents/Inbox`, with opening-in-place explicitly disabled. The source -copy is removed after intake; expired orphan copies are swept on launch. -`UIFileSharingEnabled` stays absent. The app container is not exposed in Files. +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. + +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. The extension imports GPX `com.topografix.gpx`, KML +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 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 From 48bba51fec505f356223f9ce17790ee577b34a66 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 04:01:18 +0100 Subject: [PATCH 10/13] fix(mobile): end expired intake assertions synchronously --- .../openmapx-intake/ios/IntakeAppDelegateSubscriber.swift | 6 +++++- apps/mobile/qa/results/2026-11-04-native-intake.md | 7 +++++++ docs/docs/developer/mobile-architecture.md | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift index 686b72e3e..39e93c33e 100644 --- a/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift +++ b/apps/mobile/modules/openmapx-intake/ios/IntakeAppDelegateSubscriber.swift @@ -33,7 +33,11 @@ public final class IntakeAppDelegateSubscriber: ExpoAppDelegateSubscriber { let cancellation = HostDocumentCancellation() let lifetime = IntakeBackgroundLease(invalid: UIBackgroundTaskIdentifier.invalid, begin: { expiration in application.beginBackgroundTask(withName: "OpenMapX document intake", expirationHandler: expiration) }, - end: { id in DispatchQueue.main.async { application.endBackgroundTask(id) } }) + 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() } diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 501a7842e..35c51b2da 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -38,6 +38,13 @@ Still unqualified: actual active-trip deferral; process-death/Recents replay inc ## 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. diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index a627fca30..4cff8231c 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -429,6 +429,11 @@ symlinks or blocking on FIFOs, checked with `fstat`, and rejected if its resolve 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` From 65cb2d2684fe3b830eaa7f2bc86235e96ac0bd78 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 07:13:27 +0100 Subject: [PATCH 11/13] fix(mobile): theme and structure the iOS share sheet --- apps/mobile/config/nativeIntakeConfig.ts | 4 + .../ios/Share/ShareViewController.swift | 103 +++++++++++++++--- apps/mobile/plugins/withNativeIntake.test.ts | 3 + .../qa/results/2026-11-04-native-intake.md | 4 + 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/apps/mobile/config/nativeIntakeConfig.ts b/apps/mobile/config/nativeIntakeConfig.ts index 5345faee0..0b5c989e8 100644 --- a/apps/mobile/config/nativeIntakeConfig.ts +++ b/apps/mobile/config/nativeIntakeConfig.ts @@ -1,3 +1,5 @@ +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 = [ @@ -58,6 +60,8 @@ export function intakeShareInfo(appId: string) { 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", diff --git a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift index f4f7226d9..fbb80a6cf 100644 --- a/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift +++ b/apps/mobile/modules/openmapx-intake/ios/Share/ShareViewController.swift @@ -12,32 +12,103 @@ 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 + 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 = .systemBackground - title = copy("Add to OpenMapX", "Zu OpenMapX hinzufügen") - label.numberOfLines = 0; label.textAlignment = .center; label.font = .preferredFont(forTextStyle: .body) - label.text = copy("Add this location or geographic file to OpenMapX. Then open the app to review it.", "Diesen Ort oder diese Geodatei zu OpenMapX hinzufügen. Danach die App zur Prüfung öffnen.") - add.setTitle(copy("Add to OpenMapX", "Zu OpenMapX hinzufügen"), for: .normal) - add.addTarget(self, action: #selector(save), for: .touchUpInside) - done.setTitle(copy("Cancel", "Abbrechen"), for: .normal); done.addTarget(self, action: #selector(finish), for: .touchUpInside) - let heading = UILabel(); heading.text = "OpenMapX"; heading.font = .preferredFont(forTextStyle: .title1) - let stack = UIStackView(arrangedSubviews: [heading, label, add, done]); stack.axis = .vertical; stack.spacing = 24; stack.alignment = .fill - stack.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(stack) - NSLayoutConstraint.activate([stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)]) - add.isEnabled = !providers.isEmpty && providers.count <= 5 + 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() { - guard !adding else { return }; adding = true; add.isEnabled = false + 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 @@ -89,10 +160,10 @@ final class ShareViewController: UIViewController { } catch { if operation.cancelled { return } try? IntakeContainer.store(writer: "extension").recordError((error as? IntakeFailure)?.rawValue ?? "unreadable") - DispatchQueue.main.async { self.label.text = self.copy("Some items could not be added. Open OpenMapX to review saved items.", "Einige Inhalte konnten nicht hinzugefügt werden. Gespeicherte Inhalte in OpenMapX prüfen."); self.done.setTitle(self.copy("Done", "Fertig"), for: .normal) } + DispatchQueue.main.async { self.showResult(false) } return } - DispatchQueue.main.async { self.label.text = self.copy("Added to OpenMapX. Open the app to view it.", "Zu OpenMapX hinzugefügt. Zum Ansehen die App öffnen."); self.done.setTitle(self.copy("Done", "Fertig"), for: .normal) } + DispatchQueue.main.async { self.showResult(true) } } } } diff --git a/apps/mobile/plugins/withNativeIntake.test.ts b/apps/mobile/plugins/withNativeIntake.test.ts index 9ec5252a6..da15464a1 100644 --- a/apps/mobile/plugins/withNativeIntake.test.ts +++ b/apps/mobile/plugins/withNativeIntake.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { THEME_COLORS, THEME_CORNER_RADIUS } from "../../../packages/core/src/theme"; import { intakeReceiverDeclaration } from "../config/nativeIntakeConfig"; import { intakeAndroidFilters, @@ -18,6 +19,8 @@ describe("native intake CNG", () => { 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"); diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 35c51b2da..360fa137b 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -48,3 +48,7 @@ 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. From 231727eea329027dfcfe137a0b789f2ff771330c Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 07:39:35 +0100 Subject: [PATCH 12/13] fix(mobile): recognize current map links and report intake authority failures --- .../qa/results/2026-11-04-native-intake.md | 8 ++++ apps/mobile/src/App.test.tsx | 46 +++++++++++++++++++ apps/mobile/src/App.tsx | 12 ++++- apps/mobile/src/intake/nativeIntakeModule.ts | 2 +- .../pwa/NativeIntakeHandler.test.tsx | 29 ++++++++++++ .../components/pwa/NativeIntakeHandler.tsx | 44 ++++++++++++++++-- apps/web/src/lib/mobile/nativeIntake.test.ts | 10 ++++ apps/web/src/lib/mobile/nativeIntake.ts | 4 +- docs/docs/developer/mobile-architecture.md | 7 +++ .../core/src/navigation/nativeIntake.test.ts | 34 ++++++++++++++ packages/core/src/navigation/nativeIntake.ts | 30 ++++++++++-- packages/i18n/locales/de.json | 3 ++ packages/i18n/locales/en.json | 3 ++ 13 files changed, 220 insertions(+), 12 deletions(-) diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 360fa137b..228494233 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -52,3 +52,11 @@ Local HTTP was used only for development runtime QA. Afterward, two clean HTTPS ## 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; the failure notice 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). diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 35d936d82..d9bc1005c 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -9,6 +9,7 @@ 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"; @@ -613,6 +614,51 @@ describe("App bridge channel", () => { return nonce; }; + 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 () => [], + 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 the deployed v3 capability schema while advertising the navigation graph", async () => { mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); await hello(3); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 22aa6a15f..37b6c75db 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -25,7 +25,7 @@ import { buildChannelBootstrapScript } from "./bridge/outboundScript"; import { getRuntimeConfig, type MobileRuntimeConfig } from "./config/runtimeConfig"; import { useConnectivity } from "./connectivity/useConnectivity"; import { IntakeController } from "./intake/IntakeController"; -import { createNativeIntakePort } from "./intake/nativeIntakeModule"; +import { createNativeIntakePort, NativeIntakeError } from "./intake/nativeIntakeModule"; import { createNavigationRecoveryController } from "./lifecycle/createNavigationRecoveryController"; import { useAppVisibility } from "./lifecycle/useAppLifecycle"; import { buildWebVisibilityScript } from "./lifecycle/webVisibility"; @@ -234,7 +234,15 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement send: (type, payload) => composed.bridge.send(type, payload), canApply: async () => { const runtime = await navigation; - return runtime !== null && (await runtime.activeSession()) === null; + 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 }; diff --git a/apps/mobile/src/intake/nativeIntakeModule.ts b/apps/mobile/src/intake/nativeIntakeModule.ts index bb8d457b4..41b1ff34d 100644 --- a/apps/mobile/src/intake/nativeIntakeModule.ts +++ b/apps/mobile/src/intake/nativeIntakeModule.ts @@ -20,7 +20,7 @@ interface NativeIntake { addListener(name: "onInboxChanged", listener: () => void): { remove(): void }; } export class NativeIntakeError extends Error { - constructor(readonly code: IntakeErrorCode) { + constructor(readonly code: IntakeErrorCode | "navigation-unavailable") { super(code); } } diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx index 786a5e13f..9df57db5d 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx @@ -1,6 +1,7 @@ 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(() => ({ @@ -25,6 +26,11 @@ vi.mock("next/navigation", () => ({ })); 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; @@ -173,3 +179,26 @@ it("offers postponed items again on a later foreground", async () => { 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(); +}); diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.tsx index 7be5c9567..d30c728d1 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.tsx @@ -53,6 +53,7 @@ export function NativeIntakeHandler() { [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(""); @@ -76,6 +77,7 @@ export function NativeIntakeHandler() { try { const result = await client.list(); if (generation.current !== current || version !== refreshVersion.current) return; + setListingUnavailable(""); setItems(result.items); setScheme(result.scheme); setBlocked(!result.canApply); @@ -88,8 +90,11 @@ export function NativeIntakeHandler() { setSelected(result.items.find((item) => !later.current.has(item.id))?.id ?? ""); setVisible(true); } - } catch { - /* Pending items remain owned natively; foreground or the inbox button can retry. */ + } catch (error) { + if (generation.current !== current || version !== refreshVersion.current) return; + setBlocked(true); + setListingUnavailable(error instanceof IntakeTransferError ? error.code : "unavailable"); + // Owned items and edited drafts survive a failed authority check. } }, [client, localBlocked]); useEffect(() => { @@ -168,7 +173,10 @@ export function NativeIntakeHandler() { } catch (error) { if (error instanceof IntakeTransferError && error.code === "navigation-active") setBlocked(true); - else setError(error instanceof IntakeTransferError ? error.code : "unavailable"); + 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); } @@ -222,12 +230,38 @@ export function NativeIntakeHandler() { } catch (error) { if (error instanceof IntakeTransferError && error.code === "navigation-active") setBlocked(true); - else setError(error instanceof IntakeTransferError ? error.code : "unavailable"); + 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 || !items.length) return null; + 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" && diff --git a/apps/web/src/lib/mobile/nativeIntake.test.ts b/apps/web/src/lib/mobile/nativeIntake.test.ts index 6d7094591..bc555e07d 100644 --- a/apps/web/src/lib/mobile/nativeIntake.test.ts +++ b/apps/web/src/lib/mobile/nativeIntake.test.ts @@ -109,3 +109,13 @@ it("keeps closed native failure codes without exposing arbitrary native error te }); 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 index dcd6370b8..4e95eed8c 100644 --- a/apps/web/src/lib/mobile/nativeIntake.ts +++ b/apps/web/src/lib/mobile/nativeIntake.ts @@ -17,7 +17,9 @@ 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" + (INTAKE_ERROR_CODES as readonly string[]).includes(code) || + code === "navigation-active" || + code === "navigation-unavailable" ? code : "unavailable", ); diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index 4cff8231c..1dc754be4 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -504,3 +504,10 @@ and [ContentResolver security guidance](https://developer.android.com/privacy-an 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/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts index 72e1028d2..68bc48b86 100644 --- a/packages/core/src/navigation/nativeIntake.test.ts +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -92,3 +92,37 @@ it.each([ ])("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"); +}); diff --git a/packages/core/src/navigation/nativeIntake.ts b/packages/core/src/navigation/nativeIntake.ts index 1775d09af..66a06c540 100644 --- a/packages/core/src/navigation/nativeIntake.ts +++ b/packages/core/src/navigation/nativeIntake.ts @@ -163,10 +163,19 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten ); if (url.port) return { kind: "unsupported", reason: "unsupported" }; if (url.hostname === "maps.apple.com") { - const q = url.searchParams.get("q") || url.searchParams.get("address"); + 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("ll") || "", q || "") ?? - (q ? (point(q) ?? search(q)) : { kind: "unsupported", reason: "unsupported" }) + point( + url.searchParams.get("coordinate") || + url.searchParams.get("center") || + url.searchParams.get("ll") || + "", + label, + ) ?? (q ? (point(q) ?? search(q)) : { kind: "unsupported", reason: "unsupported" }) ); } if ( @@ -177,6 +186,21 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten ) { 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) + 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 { diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index fbab2514c..a4fb8a587 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -2662,6 +2662,9 @@ "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" } }, diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 440c23712..0fc9d7a92 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -2662,6 +2662,9 @@ "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" } }, From a9aa9419b5802df17065f7876d22b48928231ca5 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 07:51:25 +0100 Subject: [PATCH 13/13] fix(mobile): preserve map intent and quiet empty intake inboxes --- .../qa/results/2026-11-04-native-intake.md | 6 ++- apps/mobile/src/App.test.tsx | 26 ++++++++++- .../src/intake/IntakeController.test.ts | 19 ++++++-- apps/mobile/src/intake/IntakeController.ts | 2 +- .../pwa/NativeIntakeHandler.test.tsx | 43 +++++++++++++++++++ .../components/pwa/NativeIntakeHandler.tsx | 16 ++++--- .../core/src/navigation/nativeIntake.test.ts | 14 ++++++ packages/core/src/navigation/nativeIntake.ts | 4 +- 8 files changed, 117 insertions(+), 13 deletions(-) diff --git a/apps/mobile/qa/results/2026-11-04-native-intake.md b/apps/mobile/qa/results/2026-11-04-native-intake.md index 228494233..db16877b1 100644 --- a/apps/mobile/qa/results/2026-11-04-native-intake.md +++ b/apps/mobile/qa/results/2026-11-04-native-intake.md @@ -57,6 +57,10 @@ The extension now receives both palettes and the corner radius from the shared c 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; the failure notice has not been requalified on a native device. +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/src/App.test.tsx b/apps/mobile/src/App.test.tsx index d9bc1005c..934621883 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -620,7 +620,15 @@ describe("App bridge channel", () => { const complete = jest.fn(); jest.spyOn(intakeModule, "createNativeIntakePort").mockReturnValue({ available: async () => true, - list: async () => [], + list: async () => [ + { + id: "f386e706-b728-40a3-9554-881d0ff0958a", + kind: "text", + name: "Shared location", + createdAtMs: 1, + byteLength: 6, + }, + ], read: jest.fn(), complete, listen: () => () => undefined, @@ -659,6 +667,22 @@ describe("App bridge channel", () => { }, ); + 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(3); diff --git a/apps/mobile/src/intake/IntakeController.test.ts b/apps/mobile/src/intake/IntakeController.test.ts index 14bd8d34e..9e5601aca 100644 --- a/apps/mobile/src/intake/IntakeController.test.ts +++ b/apps/mobile/src/intake/IntakeController.test.ts @@ -13,15 +13,17 @@ function fixture() { listen: () => () => undefined, } as NativeIntakePort; const send = jest.fn(); + const canApply = jest.fn(async () => true); const controller = new IntakeController({ port, currentNonce: () => nonce, send, - canApply: async () => true, + canApply, scheme: "openmapx", }); return { controller, + canApply, port, send, reset: () => { @@ -44,12 +46,12 @@ const message = (type: string, payload = {}) => ({ protocolVersion: 4, sentAtMs: 0, }); -it("correlates list and preserves compiled scheme and authority", async () => { +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: true, + canApply: false, scheme: "openmapx", forMessageId: "request1", }); @@ -80,3 +82,14 @@ it("never dispatches a command from an obsolete document", async () => { ); 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 index 95ebcd68c..8f2374959 100644 --- a/apps/mobile/src/intake/IntakeController.ts +++ b/apps/mobile/src/intake/IntakeController.ts @@ -21,7 +21,7 @@ export class IntakeController { switch (message.type) { case "intake.list": { const items = await this.deps.port.list(); - const canApply = await this.deps.canApply(); + const canApply = items.length > 0 ? await this.deps.canApply() : false; reply("intake.items", { items, canApply, scheme: this.deps.scheme }); break; } diff --git a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx index 9df57db5d..f33b1f0cf 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.test.tsx @@ -202,3 +202,46 @@ it("keeps a previous preview blocked when an authority refresh fails", async () 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 index d30c728d1..a0c78dfb9 100644 --- a/apps/web/src/components/pwa/NativeIntakeHandler.tsx +++ b/apps/web/src/components/pwa/NativeIntakeHandler.tsx @@ -93,7 +93,13 @@ export function NativeIntakeHandler() { } catch (error) { if (generation.current !== current || version !== refreshVersion.current) return; setBlocked(true); - setListingUnavailable(error instanceof IntakeTransferError ? error.code : "unavailable"); + 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]); @@ -189,14 +195,14 @@ export function NativeIntakeHandler() { try { const current = await client.list(); if (generation.current !== documentGeneration) return; - if (!current.canApply || localBlocked()) { - setBlocked(true); - 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 diff --git a/packages/core/src/navigation/nativeIntake.test.ts b/packages/core/src/navigation/nativeIntake.test.ts index 68bc48b86..ea591caa9 100644 --- a/packages/core/src/navigation/nativeIntake.test.ts +++ b/packages/core/src/navigation/nativeIntake.test.ts @@ -126,3 +126,17 @@ it.each([ ])("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 index 66a06c540..776769bd9 100644 --- a/packages/core/src/navigation/nativeIntake.ts +++ b/packages/core/src/navigation/nativeIntake.ts @@ -196,12 +196,12 @@ export function classifySharedText(raw: string, config: Config): SharedTextInten // 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) + 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); + const m = /^\/maps\/search\/([^/]+)(?:\/@[^/]*)?\/?$/.exec(url.pathname); if (m) { try { const value = decodeURIComponent(m[1]);