fix(mobile): fix shared file upload hanging by copying to cache
Files shared via 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. This caused uploads to hang indefinitely with a spinning indicator. Fixes: - Set LSSupportsOpeningDocumentsInPlace to false so iOS copies shared files to the app's accessible Inbox directory - Use expo-file-system to copy external file:// URIs to the app's cache directory before uploading (ensureLocalUri helper) - Apply ensureLocalUri to both initial uploads and retries Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<string> => {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user