1559686f90
- Skip known deep-link paths (qr-login, callback) in makeUrlHandler to prevent docuelevate://qr-login URLs from being treated as shared files and creating phantom upload errors - Infer MIME type from file extension for files shared via iOS Share Sheet / "Open In…" so the server receives correct Content-Type instead of application/octet-stream - Default login screen server URL to https://app.docuelevate.org Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
/**
|
||
* 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, 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.
|
||
*/
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Common MIME type mappings for file extensions.
|
||
* Used to infer the MIME type of files shared via iOS "Open In…" so the
|
||
* server receives a correct Content-Type instead of application/octet-stream.
|
||
*/
|
||
const EXT_TO_MIME: Record<string, string> = {
|
||
pdf: "application/pdf",
|
||
jpg: "image/jpeg",
|
||
jpeg: "image/jpeg",
|
||
png: "image/png",
|
||
gif: "image/gif",
|
||
bmp: "image/bmp",
|
||
tiff: "image/tiff",
|
||
tif: "image/tiff",
|
||
webp: "image/webp",
|
||
heic: "image/heic",
|
||
heif: "image/heif",
|
||
txt: "text/plain",
|
||
csv: "text/csv",
|
||
doc: "application/msword",
|
||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
xls: "application/vnd.ms-excel",
|
||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
ppt: "application/vnd.ms-powerpoint",
|
||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
rtf: "application/rtf",
|
||
html: "text/html",
|
||
xml: "application/xml",
|
||
json: "application/json",
|
||
zip: "application/zip",
|
||
};
|
||
|
||
/** Infer MIME type from a filename's extension, or undefined if unknown. */
|
||
function mimeTypeFromFilename(filename: string): string | undefined {
|
||
const ext = filename.split(".").pop()?.toLowerCase();
|
||
return ext ? EXT_TO_MIME[ext] : undefined;
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export default function NotFoundScreen() {
|
||
const pathname = usePathname();
|
||
const router = useRouter();
|
||
const { addPendingFile } = useShare();
|
||
|
||
useEffect(() => {
|
||
if (looksLikeFilePath(pathname)) {
|
||
// 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, mimeType: mimeTypeFromFilename(filename) });
|
||
router.replace("/(tabs)/");
|
||
} else {
|
||
// Truly unknown in-app route – fall back to the root redirect.
|
||
router.replace("/");
|
||
}
|
||
}, [pathname, router, addPendingFile]);
|
||
|
||
// 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",
|
||
},
|
||
});
|