From acfba4b58cced43a939765692a1cd1798f1abe7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:25:03 +0000 Subject: [PATCH] fix(mobile): handle iOS share sheet custom scheme URLs and add photo library picker On iOS the Share Sheet / "Open In" action may deliver the file path under the app's custom docuelevate:// scheme instead of a file:// URL, causing an "Unmatched Route" error. The URL handler now detects this and rewrites the URL to file:// before processing. Also adds a Photo Library button to the Upload screen so users can select existing photos from their device library, not just capture new ones with the camera. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/MobileApp.md | 15 +++++++--- mobile/README.md | 7 +++-- mobile/app/_layout.tsx | 22 ++++++++++++-- mobile/src/screens/UploadScreen.tsx | 45 +++++++++++++++++++++++++---- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 3cca38bd..5964e665 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -154,10 +154,17 @@ curl -X DELETE -H "Authorization: Bearer " https://your-server/api/mobile 3. Point the camera at the document and take a photo. 4. The image is immediately uploaded and queued for processing. +### Photo Library + +1. Open the **Upload** tab. +2. Tap **Photos**. +3. Select an existing photo from the device's photo library. +4. The image is uploaded and queued for processing. + ### File Picker 1. Open the **Upload** tab. -2. Tap **File Picker**. +2. Tap **Files**. 3. Browse to and select one or more files (PDF, DOCX, images, etc.). 4. Files are uploaded and queued for processing. @@ -174,9 +181,9 @@ The app registers itself as a share target so any file can be sent directly to D #### iOS implementation -`app.json` declares `CFBundleDocumentTypes` (with `LSHandlerRank: Alternate`) inside the iOS `infoPlist`. This tells iOS that DocuElevate can open common document types, making it visible in the share sheet without overriding system defaults. When the user selects DocuElevate, iOS opens the app with a `file://` URL via `application:openURL:options:`. +`app.json` declares `CFBundleDocumentTypes` (with `LSHandlerRank: Alternate`) inside the iOS `infoPlist`. This tells iOS that DocuElevate can open common document types, making it visible in the share sheet without overriding system defaults. When the user selects DocuElevate, iOS opens the app with a URL via `application:openURL:options:`. -The root layout listens for this URL via `expo-linking` (`Linking.addEventListener` for warm-start, `Linking.getInitialURL` for cold-start) and forwards 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`. #### Android implementation @@ -276,7 +283,7 @@ mobile/ │ └── usePushNotifications.ts # Push token registration ├── screens/ │ ├── LoginScreen.tsx # Server URL + SSO button - │ ├── UploadScreen.tsx # Camera capture + file picker + │ ├── UploadScreen.tsx # Camera capture + photo library + file picker │ ├── FilesScreen.tsx # Processed document list │ └── ProfileScreen.tsx # User profile + sign out └── services/ diff --git a/mobile/README.md b/mobile/README.md index 28f0ba37..815ab301 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -6,6 +6,7 @@ Native mobile application for DocuElevate, built with **React Native** and **Exp - 🔐 **SSO Login** – authenticate via your DocuElevate server's OAuth2/SSO provider; an API token is auto-generated and stored securely in the device keychain - 📷 **Camera Capture** – scan documents directly with the device camera +- 🖼️ **Photo Library** – select existing photos from the device's photo library for upload - 📄 **File Picker** – upload PDFs, images, and Office documents from the device's Files app - 🔗 **Share Extension** – send files from any app directly to DocuElevate via the iOS/Android share sheet - 🔔 **Push Notifications** – receive real-time push notifications when documents finish processing (via Expo push notifications) @@ -145,7 +146,7 @@ mobile/ ├── screens/ │ ├── WelcomeScreen.tsx # Branded intro / onboarding │ ├── LoginScreen.tsx # SSO login - │ ├── UploadScreen.tsx # Camera capture + file picker + │ ├── UploadScreen.tsx # Camera capture + photo library + file picker │ ├── FilesScreen.tsx # Document list │ └── ProfileScreen.tsx # User profile + sign out └── services/ @@ -158,9 +159,9 @@ The app registers itself as a share target so any file can be sent directly to D ### iOS – how it works -`app.json` declares `CFBundleDocumentTypes` in the iOS `infoPlist` section. This tells iOS which file types the app can receive, causing it to appear in the share sheet when the user shares a matching file. When the user taps **DocuElevate** in the share sheet, iOS passes the file path to the app via `application:openURL:options:`, which React Native forwards as a `file://` URL through the `Linking` module. +`app.json` declares `CFBundleDocumentTypes` in the iOS `infoPlist` section. This tells iOS which file types the app can receive, causing it to appear in the share sheet when the user shares a matching file. When the user taps **DocuElevate** in the share sheet, iOS passes the file path to the app via `application:openURL:options:`. The URL may arrive as a standard `file://` path or under the app's custom `docuelevate://` scheme. -The root layout (`app/_layout.tsx`) listens for incoming `file://` URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). 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`. **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`). diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ca5758a4..f188ed76 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -39,11 +39,29 @@ function filenameFromUri(uri: string): string { * Build a Linking URL handler that forwards incoming file:// / content:// * 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. + * + * On iOS the Share Sheet / "Open In" action may deliver the file path under + * the app's custom URL scheme (e.g. + * `docuelevate://private/var/mobile/Library/…/file.pdf`) instead of a plain + * `file://` URL. When that happens we rewrite the URL to `file:///…` so the + * upload logic can read the file normally. */ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { return ({ url }: { url: string }) => { - if (!url.startsWith("file://") && !url.startsWith("content://")) return; - addPendingFile({ uri: url, filename: filenameFromUri(url) }); + let fileUri = url; + + // iOS may pass a filesystem path under the app's custom scheme. + // Rewrite it to a file:// URL unless it looks like an in-app deep-link + // (expo-router groups always start with "("). + if (url.startsWith("docuelevate://")) { + const path = url.slice("docuelevate://".length); + if (path.length > 0 && !path.startsWith("(")) { + fileUri = "file:///" + path.replace(/^\/+/, ""); + } + } + + if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return; + addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) }); }; } diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index ca02382d..78cf8097 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -1,10 +1,11 @@ /** - * UploadScreen – document upload via camera or file picker. + * UploadScreen – document upload via camera, photo library, or file picker. * * Users can: * 1. Take a photo of a document with the device camera. - * 2. Pick an existing file (PDF, image, Office document) from the Files app. - * 3. Receive files shared from other apps via the iOS Share Sheet / Android + * 2. Select an existing photo from the device's photo library. + * 3. Pick an existing file (PDF, image, Office document) from the Files app. + * 4. Receive files shared from other apps via the iOS Share Sheet / Android * Share Intent (handled via ShareContext populated by the root layout). * * After a successful upload the screen polls the backend every 5 seconds to @@ -161,6 +162,29 @@ export default function UploadScreen() { } } + async function handlePhotoLibrary() { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== "granted") { + Alert.alert( + "Photo library access required", + "Please grant photo library access in Settings to select images." + ); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + quality: 0.9, + allowsEditing: false, + }); + + if (!result.canceled && result.assets.length > 0) { + const asset = result.assets[0]; + const filename = asset.fileName ?? `photo_${Date.now()}.jpg`; + await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg"); + } + } + async function handleFilePicker() { try { const result = await DocumentPicker.getDocumentAsync({ @@ -201,6 +225,16 @@ export default function UploadScreen() { Camera + + 🖼️ + Photos + + 📄 - File Picker + Files @@ -218,7 +252,7 @@ export default function UploadScreen() { ☁️ - Tap Camera or File Picker to upload a document. + Tap Camera, Photos, or Files to upload a document. You can also share files from other apps directly to DocuElevate. @@ -304,6 +338,7 @@ const styles = StyleSheet.create({ minHeight: 80, }, cameraButton: { backgroundColor: "#1e40af" }, + photoLibraryButton: { backgroundColor: "#7c3aed" }, fileButton: { backgroundColor: "#059669" }, actionIcon: { fontSize: 28, marginBottom: 6 }, actionLabel: {