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.
|
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)
|
##### 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).
|
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.",
|
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
|
||||||
"UIBackgroundModes": ["remote-notification"],
|
"UIBackgroundModes": ["remote-notification"],
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"LSSupportsOpeningDocumentsInPlace": true,
|
"LSSupportsOpeningDocumentsInPlace": false,
|
||||||
"CFBundleDocumentTypes": [
|
"CFBundleDocumentTypes": [
|
||||||
{
|
{
|
||||||
"CFBundleTypeName": "All Documents",
|
"CFBundleTypeName": "All Documents",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DocumentPicker from "expo-document-picker";
|
import * as DocumentPicker from "expo-document-picker";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -66,12 +67,47 @@ export default function UploadScreen() {
|
|||||||
// Core helpers (declared before the effects that depend on them)
|
// 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 uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
|
||||||
const id = `${Date.now()}-${filename}`;
|
const id = `${Date.now()}-${filename}`;
|
||||||
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
||||||
|
|
||||||
try {
|
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) =>
|
setUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === id
|
item.id === id
|
||||||
@@ -85,7 +121,7 @@ export default function UploadScreen() {
|
|||||||
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ensureLocalUri]);
|
||||||
|
|
||||||
const retryUpload = useCallback(async (item: UploadItem) => {
|
const retryUpload = useCallback(async (item: UploadItem) => {
|
||||||
if (!item.uri) return;
|
if (!item.uri) return;
|
||||||
@@ -100,7 +136,8 @@ export default function UploadScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
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) =>
|
setUploads((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
u.id === item.id
|
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))
|
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ensureLocalUri]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Polling – check server-side processing status every 5 seconds
|
// Polling – check server-side processing status every 5 seconds
|
||||||
|
|||||||
Reference in New Issue
Block a user