diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 8b8e6f79..139c1456 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -198,6 +198,24 @@ The app registers itself as a share target so any file can be sent directly to D The URL may arrive as a standard `file://` path **or** under the app's custom `docuelevate://` scheme (e.g. `docuelevate://private/var/mobile/Library/…/file.pdf`). The root layout detects the custom-scheme form and rewrites it to a `file://` URL before forwarding it to the Upload screen through `ShareContext`. +##### Handling "unmatched route" errors from "Open In…" + +iOS sometimes delivers the file path under the `docuelevate://` scheme, e.g.: + +``` +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. + +##### iOS Action / Share Extension (future enhancement) + +Apps like DeepL ("Translate in DeepL") and Microsoft Word ("Convert to Word") appear as **Action Extensions** in the iOS share sheet — a system-level feature that requires a separate Xcode target built with Swift or Objective-C. A proper Action Extension runs in its own process and must share authentication credentials with the main app via an iOS **App Group** (shared keychain / shared container). + +This level of iOS-native integration is a planned future enhancement. Until it is available, the recommended workflow is the current one: tap **Share → DocuElevate** (the app appears in the "Open With" row of the share sheet via `CFBundleDocumentTypes`). + #### Android implementation `app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS. diff --git a/mobile/README.md b/mobile/README.md index 815ab301..4e3a4637 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -163,6 +163,16 @@ The app registers itself as a share target so any file can be sent directly to D The root layout (`app/_layout.tsx`) listens for incoming URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). If the URL uses the `docuelevate://` scheme it is automatically rewritten to `file://` before being forwarded. Incoming files are stored in `ShareContext` and automatically uploaded by `UploadScreen`. +#### Handling "unmatched route" errors from "Open In…" + +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. + **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`). To use the share sheet: @@ -174,6 +184,10 @@ To use the share sheet: > **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type. +#### iOS Action Extension (future enhancement) + +Apps like DeepL ("Translate in DeepL") appear as **Action Extensions** in the iOS share sheet, which requires a separate Xcode target and native Swift code. This is planned as a future enhancement. The current `CFBundleDocumentTypes` approach places DocuElevate in the "Open With" row of the share sheet. + ### Android – how it works `app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS. diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx new file mode 100644 index 00000000..0c41c7f7 --- /dev/null +++ b/mobile/app/+not-found.tsx @@ -0,0 +1,119 @@ +/** + * Catch-all "not found" route for expo-router. + * + * This screen intercepts two different situations: + * + * 1. **iOS "Open In…" / share sheet** — iOS delivers files to the app via a + * `docuelevate://` URL. expo-router strips the custom scheme and + * tries to match the raw filesystem path (e.g. + * `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because + * 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. + * + * 2. **Any other unmatched in-app route** — redirect silently to the root so + * the user isn't left on a blank error page. + */ + +import { usePathname, useRouter } from "expo-router"; +import React, { useEffect } from "react"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * First path-segment names that identify iOS/Android sandbox filesystem paths. + * These can never be expo-router route-group names, so their presence is a + * strong positive signal that the URL is a shared file rather than a route. + * + * iOS: /private/var/mobile/… → "private" + * /var/mobile/… → "var" (symlink to /private/var/mobile) + * /tmp/… → "tmp" + * Android: /data/user/0/… → "data" + * /storage/emulated/0/… → "storage" + */ +const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"]; + +/** + * Route-group / special-file prefixes that identify genuine in-app routes + * rather than filesystem path segments. + * + * ⚠️ Keep this list in sync with the top-level entries in the `app/` + * directory. Add an entry here if you add a new top-level route group + * that does **not** use the parentheses convention. + */ +const IN_APP_ROUTE_PREFIXES = [ + "(auth)", // app/(auth)/ + "(tabs)", // app/(tabs)/ + "_", // expo-router special files (_layout, _sitemap, …) + "+", // expo-router special files (+not-found, …) + "--", // Expo Go development proxy prefix +]; + +/** + * Return `true` when `pathname` looks like a filesystem path delivered by iOS + * "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a + * legitimate in-app route. + * + * Detection strategy: + * 1. **Positive check** – if the first path segment matches a known device + * filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path. + * 2. **Fallback negative check** – if the path does not start with any known + * in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file + * path. This is a heuristic but safe because expo-router route groups + * always use parentheses (e.g. `(auth)`, `(tabs)`). + */ +function looksLikeFilePath(pathname: string): boolean { + const stripped = pathname.replace(/^\/+/, ""); + if (stripped.length === 0) return false; + + // Positive signal: path starts with a known device filesystem root segment. + const firstSegment = stripped.split("/")[0]; + if (FS_PATH_ROOTS.includes(firstSegment)) return true; + + // Fallback: paths that start with a known in-app route prefix are routes. + return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Screen component +// --------------------------------------------------------------------------- + +export default function NotFoundScreen() { + const pathname = usePathname(); + const router = useRouter(); + + 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. + router.replace("/(tabs)/"); + } else { + // Truly unknown in-app route – fall back to the root redirect. + router.replace("/"); + } + }, [pathname, router]); + + // Show a brief spinner while the redirect is in flight. + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f9fafb", + }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 7202a704..b86a5583 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -9,6 +9,11 @@ * sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming * file:// / content:// URL is captured and forwarded to UploadScreen via * ShareContext. + * + * 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. */ import * as Linking from "expo-linking"; @@ -43,10 +48,16 @@ function filenameFromUri(uri: string): string { * URLs to ShareContext. Extracted as a module-level factory so the handler * itself is created once and can be easily unit-tested without a React context. * - * On iOS the Share Sheet / "Open In" action may deliver the file path under + * On iOS the Share Sheet / "Open In…" action may deliver the file path under * the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain * `file://` URL. When that happens we rewrite the URL to `file:///…` so the * upload logic can read the file normally. + * + * 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. */ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { return ({ url }: { url: string }) => { @@ -121,6 +132,8 @@ function AuthGuard() { + {/* +not-found handles unmatched routes such as iOS "Open In…" file paths */} + ); }