diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 75ab2ee7..7fac8c3b 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -210,6 +210,14 @@ expo-router strips the scheme and tries to match `/private/var/mobile/…` as an 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. +##### File accessibility and local caching + +Shared files may reference paths outside the app's sandbox or use security-scoped URLs that React Native's `fetch` cannot read directly. To guarantee reliable uploads: + +- **`LSSupportsOpeningDocumentsInPlace`** is set to `false` in `app.json`, which tells iOS to copy shared files into the app's `Documents/Inbox` directory before handing them to the app. +- **`UploadScreen`** uses `expo-file-system` (`FileSystem.copyAsync`) to copy any `file://` URI that is outside the app's cache/documents directory to a local cache path before uploading. This ensures the file is readable regardless of its origin. +- **MIME type inference**: Both `+not-found.tsx` and the `Linking` handler in `_layout.tsx` infer the MIME type from the file extension (e.g. `.pdf` → `application/pdf`) so the server receives a correct `Content-Type` instead of `application/octet-stream`. + ##### 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). diff --git a/mobile/app.json b/mobile/app.json index 1e3ef852..0e755fed 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -24,7 +24,7 @@ "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", "UIBackgroundModes": ["remote-notification"], "ITSAppUsesNonExemptEncryption": false, - "LSSupportsOpeningDocumentsInPlace": true, + "LSSupportsOpeningDocumentsInPlace": false, "CFBundleDocumentTypes": [ { "CFBundleTypeName": "All Documents", diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index ea3ae1e6..d883dd5c 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -14,6 +14,7 @@ import { Ionicons } from "@expo/vector-icons"; import * as DocumentPicker from "expo-document-picker"; +import * as FileSystem from "expo-file-system"; import * as ImagePicker from "expo-image-picker"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { @@ -66,12 +67,47 @@ export default function UploadScreen() { // Core helpers (declared before the effects that depend on them) // --------------------------------------------------------------------------- + /** + * Ensure a file URI is accessible for upload. + * + * Files received via the iOS Share Sheet / "Open In…" may reference paths + * outside the app's sandbox or use security-scoped URLs that React Native's + * fetch cannot read directly. This helper copies such files to the app's + * cache directory so the upload can proceed reliably. + * + * URIs from expo-image-picker and expo-document-picker are already in the + * app's cache and are returned unchanged. + */ + const ensureLocalUri = useCallback(async (uri: string, filename: string): Promise => { + // Android content:// URIs are handled natively by React Native's fetch. + if (!uri.startsWith("file://")) return uri; + + // Files already in the app's cache or documents directory are accessible. + const cacheDir = FileSystem.cacheDirectory; + const docDir = FileSystem.documentDirectory; + if (cacheDir && uri.startsWith(cacheDir)) return uri; + if (docDir && uri.startsWith(docDir)) return uri; + + // External file (e.g. from iOS Inbox or security-scoped URL) – copy to + // cache so the upload has guaranteed read access. + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + const destUri = `${cacheDir}shared_${Date.now()}_${safeName}`; + try { + await FileSystem.copyAsync({ from: uri, to: destUri }); + return destUri; + } catch { + // Copy failed – fall back to the original URI (might work for some paths). + return uri; + } + }, []); + const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => { const id = `${Date.now()}-${filename}`; setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]); try { - const resp = await api.uploadFile(uri, filename, mimeType); + const localUri = await ensureLocalUri(uri, filename); + const resp = await api.uploadFile(localUri, filename, mimeType); setUploads((prev) => prev.map((item) => item.id === id @@ -85,7 +121,7 @@ export default function UploadScreen() { prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item)) ); } - }, []); + }, [ensureLocalUri]); const retryUpload = useCallback(async (item: UploadItem) => { if (!item.uri) return; @@ -100,7 +136,8 @@ export default function UploadScreen() { ); try { - const resp = await api.uploadFile(item.uri, item.filename, item.mimeType); + const localUri = await ensureLocalUri(item.uri, item.filename); + const resp = await api.uploadFile(localUri, item.filename, item.mimeType); setUploads((prev) => prev.map((u) => u.id === item.id @@ -114,7 +151,7 @@ export default function UploadScreen() { prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u)) ); } - }, []); + }, [ensureLocalUri]); // --------------------------------------------------------------------------- // Polling – check server-side processing status every 5 seconds