From 71a7a57adc1bb6de88c060dba563afa433283e38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:12:34 +0000 Subject: [PATCH] fix(mobile): add shared file to ShareContext directly in +not-found.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When iOS delivers a file via "Open In…", expo-router strips the docuelevate:// scheme and routes to +not-found.tsx. Previously, this screen only redirected to the Upload tab and relied on the Linking handler in _layout.tsx to add the file to ShareContext. This was unreliable because expo-router may consume the URL event before the Linking handler fires. Now +not-found.tsx directly reconstructs the file:// URI from the pathname and adds it to ShareContext before redirecting. ShareContext deduplicates by URI to prevent double uploads if both mechanisms fire. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/MobileApp.md | 2 +- mobile/README.md | 2 +- mobile/app/+not-found.tsx | 43 +++++++++++++++++++++++------ mobile/app/_layout.tsx | 10 +++---- mobile/src/context/ShareContext.tsx | 11 +++++++- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 139c1456..e1a147c6 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -208,7 +208,7 @@ docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. Because no such route exists, it previously threw an **"unmatched route docuelevate://"** error and the upload never completed. -The fix is a catch-all `+not-found.tsx` route (see `mobile/app/+not-found.tsx`). When expo-router cannot match the path, it renders this screen instead. The screen detects that the path is a filesystem path rather than a real in-app route and immediately redirects to the Upload tab. The `Linking` listener registered in the root layout has concurrently (or will shortly) added the file to `ShareContext`, so the upload proceeds normally once the user lands on the Upload tab. +The fix is a catch-all `+not-found.tsx` route (see `mobile/app/+not-found.tsx`). When expo-router cannot match the path, it renders this screen instead. The screen detects that the path is a filesystem path rather than a real in-app route, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext.addPendingFile` deduplicates by URI so the file is only uploaded once. ##### iOS Action / Share Extension (future enhancement) diff --git a/mobile/README.md b/mobile/README.md index 4e3a4637..601d71c3 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -171,7 +171,7 @@ iOS sometimes delivers the file path under the `docuelevate://` scheme: docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf ``` -expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. The catch-all `app/+not-found.tsx` intercepts this, detects the filesystem-path pattern, and immediately redirects to the Upload tab. The file — already added to `ShareContext` by the `Linking` listener — is then uploaded automatically. +expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. The catch-all `app/+not-found.tsx` intercepts this, detects the filesystem-path pattern, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext` deduplicates by URI to prevent double uploads. **Supported iOS file types:** PDF, images (JPEG / PNG / GIF / BMP / TIFF / WebP), plain text, Word (`.docx`, `.doc`), Excel (`.xlsx`, `.xls`), PowerPoint (`.pptx`, `.ppt`), and any other file (`public.data`). diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx index 0c41c7f7..e283f1f0 100644 --- a/mobile/app/+not-found.tsx +++ b/mobile/app/+not-found.tsx @@ -10,10 +10,13 @@ * no such route exists, expo-router previously threw "unmatched route * docuelevate://…" and the upload never happened. * - * This screen detects the filesystem-path pattern and immediately redirects - * to the Upload tab. The `Linking` listener registered in `_layout.tsx` - * runs concurrently and adds the file to `ShareContext`; `UploadScreen` - * picks it up and begins uploading as soon as the redirect completes. + * This screen detects the filesystem-path pattern, adds the file directly + * to `ShareContext`, and redirects to the Upload tab. `UploadScreen` + * picks up the pending file and begins uploading automatically. + * + * The `Linking` listener in `_layout.tsx` may also fire for the same URL; + * `ShareContext.addPendingFile` deduplicates by URI so the file is only + * uploaded once. * * 2. **Any other unmatched in-app route** — redirect silently to the root so * the user isn't left on a blank error page. @@ -22,6 +25,7 @@ import { usePathname, useRouter } from "expo-router"; import React, { useEffect } from "react"; import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { useShare } from "../src/context/ShareContext"; // --------------------------------------------------------------------------- // Helpers @@ -81,6 +85,21 @@ function looksLikeFilePath(pathname: string): boolean { return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix)); } +/** + * Extract a display filename from a filesystem path. + * Handles URL-encoded characters and strips query strings. + */ +function filenameFromPath(pathname: string): string { + try { + const decoded = decodeURIComponent(pathname); + const segments = decoded.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? "shared_file"; + return last.split("?")[0] || "shared_file"; + } catch { + return "shared_file"; + } +} + // --------------------------------------------------------------------------- // Screen component // --------------------------------------------------------------------------- @@ -88,18 +107,26 @@ function looksLikeFilePath(pathname: string): boolean { export default function NotFoundScreen() { const pathname = usePathname(); const router = useRouter(); + const { addPendingFile } = useShare(); useEffect(() => { if (looksLikeFilePath(pathname)) { - // Filesystem path from iOS "Open In…" – redirect to Upload tab. - // The Linking listener in _layout.tsx has already (or will shortly) - // added the file to ShareContext; UploadScreen will pick it up. + // Filesystem path from iOS "Open In…" – add the file to ShareContext + // and redirect to the Upload tab. UploadScreen will pick up the + // pending file and begin uploading automatically. + // + // The pathname from expo-router is the raw filesystem path + // (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a + // file:// URI so the upload logic can read the file. + const fileUri = `file://${pathname}`; + const filename = filenameFromPath(pathname); + addPendingFile({ uri: fileUri, filename }); router.replace("/(tabs)/"); } else { // Truly unknown in-app route – fall back to the root redirect. router.replace("/"); } - }, [pathname, router]); + }, [pathname, router, addPendingFile]); // Show a brief spinner while the redirect is in flight. return ( diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index b86a5583..34827808 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -12,8 +12,8 @@ * * The companion `+not-found.tsx` handles the case where expo-router receives * a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and - * cannot match it to a route. It detects the pattern and redirects to the - * Upload tab so the file — already in ShareContext — is uploaded transparently. + * cannot match it to a route. It adds the file directly to ShareContext and + * redirects to the Upload tab so the file is uploaded transparently. */ import * as Linking from "expo-linking"; @@ -55,9 +55,9 @@ function filenameFromUri(uri: string): string { * * Note: expo-router also receives the same URL and will attempt to match it as * an in-app route. When no route matches it renders `+not-found.tsx`, which - * redirects to the Upload tab. This handler and `+not-found.tsx` work in - * concert: this handler adds the file to ShareContext, and `+not-found.tsx` - * ensures the user lands on the Upload tab so the file is uploaded. + * adds the file to ShareContext directly and redirects to the Upload tab. + * Both this handler and `+not-found.tsx` call `addPendingFile`; + * `ShareContext` deduplicates by URI so the file is only uploaded once. */ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { return ({ url }: { url: string }) => { diff --git a/mobile/src/context/ShareContext.tsx b/mobile/src/context/ShareContext.tsx index 43643bdb..08519e8f 100644 --- a/mobile/src/context/ShareContext.tsx +++ b/mobile/src/context/ShareContext.tsx @@ -32,7 +32,16 @@ export function ShareProvider({ children }: { children: React.ReactNode }) { const [pendingFiles, setPendingFiles] = useState([]); const addPendingFile = useCallback((file: SharedFile) => { - setPendingFiles((prev) => [...prev, file]); + setPendingFiles((prev) => { + // Deduplicate by normalised URI so the same file is not uploaded twice + // when both the Linking handler (_layout.tsx) and +not-found.tsx fire. + const normalize = (uri: string) => { + try { return decodeURIComponent(uri); } catch { return uri; } + }; + const norm = normalize(file.uri); + if (prev.some((f) => normalize(f.uri) === norm)) return prev; + return [...prev, file]; + }); }, []); const clearPendingFiles = useCallback(() => {