fix(mobile): resolve iOS "unmatched route docuelevate://" error in Open In share flow
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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`.
|
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
|
#### 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.
|
`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.
|
||||||
|
|||||||
@@ -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`.
|
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`).
|
**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:
|
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.
|
> **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
|
### 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.
|
`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.
|
||||||
|
|||||||
@@ -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://<path>` 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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color="#1e40af" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
},
|
||||||
|
});
|
||||||
+14
-1
@@ -9,6 +9,11 @@
|
|||||||
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
|
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
|
||||||
* file:// / content:// URL is captured and forwarded to UploadScreen via
|
* file:// / content:// URL is captured and forwarded to UploadScreen via
|
||||||
* ShareContext.
|
* 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";
|
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
|
* 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.
|
* 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
|
* 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
|
* `file://` URL. When that happens we rewrite the URL to `file:///…` so the
|
||||||
* upload logic can read the file normally.
|
* 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) {
|
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) {
|
||||||
return ({ url }: { url: string }) => {
|
return ({ url }: { url: string }) => {
|
||||||
@@ -121,6 +132,8 @@ function AuthGuard() {
|
|||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="(auth)" />
|
<Stack.Screen name="(auth)" />
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
|
{/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user