From 4de6b439ceddee2cb047771af594851fa2b10244 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:52:54 +0000 Subject: [PATCH] fix(mobile): iOS share sheet, empty files tab, and stuck upload status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.json: add CFBundleDocumentTypes to iOS infoPlist so the app appears in the iOS Share Sheet; add ACTION_SEND/SEND_MULTIPLE intentFilters for Android share intent support - src/context/ShareContext.tsx (new): React context that queues files received from the share sheet and delivers them to UploadScreen - app/_layout.tsx: wrap in ShareProvider; add Linking handler (makeUrlHandler factory + getInitialURL cold-start + addEventListener warm-start) to capture file:// and content:// URLs - src/services/api.ts: fix FileRecord interface (original_filename, nested ProcessingStatus, mime_type); fix UploadResponse interface; fix listFiles() (per_page param, unwrap data.files); add getFileStatus(fileId) for single-file status polling - src/screens/FilesScreen.tsx: use file.original_filename and file.processing_status.status; fix statusEmoji to use actual backend status values (completed/pending/duplicate) - src/screens/UploadScreen.tsx: consume ShareContext for auto-upload of shared files; add 5-second polling loop (search by filename → file_id → getFileStatus) to show real-time server processing status; uploadFile wrapped in useCallback; proper effect dependency arrays Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/MobileApp.md | 19 +++- mobile/README.md | 32 ++++-- mobile/app.json | 34 ++++++- mobile/app/_layout.tsx | 60 +++++++++++- mobile/src/context/ShareContext.tsx | 51 ++++++++++ mobile/src/screens/FilesScreen.tsx | 13 +-- mobile/src/screens/UploadScreen.tsx | 145 ++++++++++++++++++++++++---- mobile/src/services/api.ts | 37 ++++--- 8 files changed, 341 insertions(+), 50 deletions(-) create mode 100644 mobile/src/context/ShareContext.tsx diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 2d697c98..7840da22 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -140,10 +140,24 @@ The app registers itself as a share target so any file can be sent directly to D 1. Open a file in Files, Mail, Safari, or any other app. 2. Tap the **Share** button (iOS) or **Share** (Android). 3. Find and tap **DocuElevate** in the share sheet. -4. The file is immediately uploaded. +4. The file is immediately uploaded and queued for processing. > **Note:** The app must be installed on the device for it to appear in the share sheet. +#### 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:`. + +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`. + +#### Android implementation + +`app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS. + +#### Upload status polling + +After a file is uploaded the app polls `/api/files?search=` every 5 seconds to find the corresponding `FileRecord`, then polls `/api/files/{id}` to track the processing status in real time. Polling stops automatically once the status reaches a terminal state (`completed`, `failed`, or `duplicate`). + ## Mobile API Endpoints The backend exposes a dedicated `/api/mobile/` namespace: @@ -228,7 +242,8 @@ mobile/ ├── tsconfig.json └── src/ ├── context/ - │ └── AuthContext.tsx # Auth state + SSO login flow + │ ├── AuthContext.tsx # Auth state + SSO login flow + │ └── ShareContext.tsx # Shared-file queue (iOS Share Sheet / Android Intent) ├── hooks/ │ └── usePushNotifications.ts # Push token registration ├── screens/ diff --git a/mobile/README.md b/mobile/README.md index 03ec4b3d..592bc67a 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -119,7 +119,8 @@ mobile/ ├── tsconfig.json └── src/ ├── context/ - │ └── AuthContext.tsx # Authentication state management + │ ├── AuthContext.tsx # Authentication state management + │ └── ShareContext.tsx # Shared-file queue (iOS Share Sheet / Android Intent) ├── hooks/ │ └── usePushNotifications.ts # Push notification registration ├── screens/ @@ -132,16 +133,30 @@ mobile/ └── api.ts # DocuElevate API client ``` -## Share Extension (iOS) +## Share Sheet (iOS) / Share Intent (Android) -The app registers the `docuelevate://` URL scheme and the `com.docuelevate.app` bundle identifier. To enable the share sheet: +The app registers itself as a share target so any file can be sent directly to DocuElevate from another app. -1. Ensure the app is installed on the device -2. Open any file in Files, Mail, Safari, etc. -3. Tap the share icon → find **DocuElevate** in the share sheet -4. The file is uploaded immediately +### iOS – how it works -Android uses a similar intent filter configured in `app.json`. +`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. + +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`. + +**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`). + +To use the share sheet: + +1. Ensure the app is installed on the device. +2. Open any supported file in Files, Mail, Safari, etc. +3. Tap the **Share** button → find **DocuElevate** in the share sheet. +4. The file is uploaded immediately. + +> **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type. + +### Android – how it works + +`app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS. ## Backend API @@ -156,6 +171,7 @@ The mobile app uses the following backend endpoints: | `GET` | `/api/mobile/whoami` | Get current user profile | | `POST` | `/api/ui-upload` | Upload file for processing | | `GET` | `/api/files` | List processed documents | +| `GET` | `/api/files/{id}` | Get processing status of a single file | Authentication uses `Authorization: Bearer ` on all requests. diff --git a/mobile/app.json b/mobile/app.json index 38c986fb..ea357570 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -21,7 +21,27 @@ "NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.", "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", "UIBackgroundModes": ["fetch", "remote-notification"], - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "All Documents", + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Alternate", + "LSItemContentTypes": [ + "public.content", + "public.data", + "public.image", + "com.adobe.pdf", + "public.plain-text", + "org.openxmlformats.wordprocessingml.document", + "org.openxmlformats.spreadsheetml.sheet", + "org.openxmlformats.presentationml.presentation", + "com.microsoft.word.doc", + "com.microsoft.excel.xls", + "com.microsoft.powerpoint.ppt" + ] + } + ] }, "buildNumber": "3" }, @@ -38,6 +58,18 @@ "RECEIVE_BOOT_COMPLETED", "VIBRATE" ], + "intentFilters": [ + { + "action": "SEND", + "data": [{ "mimeType": "*/*" }], + "category": ["DEFAULT"] + }, + { + "action": "SEND_MULTIPLE", + "data": [{ "mimeType": "*/*" }], + "category": ["DEFAULT"] + } + ], "versionCode": 1 }, "web": { diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 2c3e02e8..ca5758a4 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,13 +4,48 @@ * Wraps the entire app in AuthProvider and SafeAreaProvider, then uses the * AuthGuard component to redirect between the unauthenticated (auth) route * group and the authenticated (tabs) route group based on session state. + * + * ShareProvider + Linking listener: when iOS opens the app via the share + * sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming + * file:// / content:// URL is captured and forwarded to UploadScreen via + * ShareContext. */ +import * as Linking from "expo-linking"; import { Stack, useRouter, useSegments } from "expo-router"; import React, { useEffect } from "react"; import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { AuthProvider, useAuth } from "../src/context/AuthContext"; +import { ShareProvider, useShare } from "../src/context/ShareContext"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract a display filename from a file:// or content:// URI. */ +function filenameFromUri(uri: string): string { + try { + const decoded = decodeURIComponent(uri); + // Take the last path segment and strip any query string + const last = decoded.split("/").pop() ?? "shared_file"; + return last.split("?")[0] || "shared_file"; + } catch { + return "shared_file"; + } +} + +/** + * 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. + */ +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) }); + }; +} // --------------------------------------------------------------------------- // Auth guard – redirects to the correct route group after auth state resolves @@ -18,9 +53,26 @@ import { AuthProvider, useAuth } from "../src/context/AuthContext"; function AuthGuard() { const { isLoading, isAuthenticated } = useAuth(); + const { addPendingFile } = useShare(); const segments = useSegments(); const router = useRouter(); + // Listen for files shared from other apps (iOS Share Sheet / Android Intent). + // Both cold-start (app was not running) and warm-start (app in background) + // cases are handled. + useEffect(() => { + const handleIncomingUrl = makeUrlHandler(addPendingFile); + + // Cold start – app launched directly by a share action + Linking.getInitialURL().then((url) => { + if (url) handleIncomingUrl({ url }); + }); + + // Warm start – app was already running when the share action occurred + const subscription = Linking.addEventListener("url", handleIncomingUrl); + return () => subscription.remove(); + }, [addPendingFile]); + useEffect(() => { if (isLoading) return; @@ -59,9 +111,11 @@ function AuthGuard() { export default function RootLayout() { return ( - - - + + + + + ); } diff --git a/mobile/src/context/ShareContext.tsx b/mobile/src/context/ShareContext.tsx new file mode 100644 index 00000000..43643bdb --- /dev/null +++ b/mobile/src/context/ShareContext.tsx @@ -0,0 +1,51 @@ +/** + * ShareContext – propagates files received from the iOS Share Sheet or the + * Android Share Intent to the UploadScreen so they can be uploaded + * automatically. + * + * The root layout listens for incoming file:// / content:// URLs via + * expo-linking and calls addPendingFile(). UploadScreen consumes the context, + * uploads each pending file, then calls clearPendingFiles(). + */ + +import React, { createContext, useCallback, useContext, useState } from "react"; + +export interface SharedFile { + uri: string; + filename: string; + mimeType?: string; +} + +interface ShareContextValue { + pendingFiles: SharedFile[]; + addPendingFile: (file: SharedFile) => void; + clearPendingFiles: () => void; +} + +const ShareContext = createContext({ + pendingFiles: [], + addPendingFile: () => {}, + clearPendingFiles: () => {}, +}); + +export function ShareProvider({ children }: { children: React.ReactNode }) { + const [pendingFiles, setPendingFiles] = useState([]); + + const addPendingFile = useCallback((file: SharedFile) => { + setPendingFiles((prev) => [...prev, file]); + }, []); + + const clearPendingFiles = useCallback(() => { + setPendingFiles([]); + }, []); + + return ( + + {children} + + ); +} + +export function useShare(): ShareContextValue { + return useContext(ShareContext); +} diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx index 2cec7c13..3a2a21d4 100644 --- a/mobile/src/screens/FilesScreen.tsx +++ b/mobile/src/screens/FilesScreen.tsx @@ -36,11 +36,11 @@ function formatDate(iso: string): string { function statusEmoji(status: string): string { const map: Record = { - processed: "✅", + completed: "✅", processing: "⚙️", - queued: "⏳", + pending: "⏳", failed: "❌", - uploaded: "⬆️", + duplicate: "🔁", }; return map[status?.toLowerCase()] ?? "📄"; } @@ -143,18 +143,19 @@ export default function FilesScreen() { } function FileRow({ file }: { file: FileRecord }) { + const status = file.processing_status?.status ?? "pending"; return ( - {statusEmoji(file.status)} + {statusEmoji(status)} - {file.filename} + {file.original_filename} {formatDate(file.created_at)} · {formatBytes(file.file_size)} - {file.status} + {status} ); } diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index fa827f72..ca02382d 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -5,12 +5,15 @@ * 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 - * Share Intent (handled by the expo-sharing + deep-link integration). + * Share Intent (handled via ShareContext populated by the root layout). + * + * After a successful upload the screen polls the backend every 5 seconds to + * track the real-time processing status of each uploaded file. */ import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, @@ -21,41 +24,119 @@ import { View, } from "react-native"; import { useAuth } from "../context/AuthContext"; +import { useShare } from "../context/ShareContext"; import api from "../services/api"; +/** Statuses that indicate processing has finished (no further polling needed). */ +const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]); + interface UploadItem { id: string; filename: string; status: "pending" | "uploading" | "done" | "error"; error?: string; taskId?: string; + /** Sanitised filename returned by the server – used to search for the record. */ + originalFilename?: string; + /** Database ID of the FileRecord once it has been created by the worker. */ + fileId?: number; + /** Actual server-side processing status (e.g. "pending", "processing", "completed"). */ + serverStatus?: string; } export default function UploadScreen() { const { isAuthenticated } = useAuth(); + const { pendingFiles, clearPendingFiles } = useShare(); const [uploads, setUploads] = useState([]); - function updateItem(id: string, patch: Partial) { - setUploads((prev) => - prev.map((item) => (item.id === id ? { ...item, ...patch } : item)) - ); - } + // Keep a ref in sync so the polling interval can read current state without + // capturing a stale closure. + const uploadsRef = useRef([]); + useEffect(() => { + uploadsRef.current = uploads; + }, [uploads]); - async function uploadFile(uri: string, filename: string, mimeType?: string) { + // --------------------------------------------------------------------------- + // Core helpers (declared before the effects that depend on them) + // --------------------------------------------------------------------------- + + const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => { const id = `${Date.now()}-${filename}`; - setUploads((prev) => [ - { id, filename, status: "uploading" }, - ...prev, - ]); + setUploads((prev) => [{ id, filename, status: "uploading" }, ...prev]); try { const resp = await api.uploadFile(uri, filename, mimeType); - updateItem(id, { status: "done", taskId: resp.task_id }); + setUploads((prev) => + prev.map((item) => + item.id === id + ? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename } + : item + ) + ); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Upload failed"; - updateItem(id, { status: "error", error: msg }); + setUploads((prev) => + prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item)) + ); } - } + }, []); + + // --------------------------------------------------------------------------- + // Polling – check server-side processing status every 5 seconds + // --------------------------------------------------------------------------- + useEffect(() => { + const poll = async () => { + const current = uploadsRef.current; + for (const item of current) { + // Only poll items that were successfully uploaded and haven't reached a + // terminal status yet. + if (item.status !== "done") continue; + if (item.serverStatus && TERMINAL_STATUSES.has(item.serverStatus)) continue; + + try { + if (item.fileId !== undefined) { + // We already know the file ID – just refresh its status. + const ps = await api.getFileStatus(item.fileId); + setUploads((prev) => + prev.map((u) => (u.id === item.id ? { ...u, serverStatus: ps.status } : u)) + ); + } else if (item.originalFilename) { + // Search for the file by name; it may not exist yet if the worker + // hasn't started. + const files = await api.listFiles(1, 5, item.originalFilename); + const found = files.find((f) => f.original_filename === item.originalFilename); + if (found) { + setUploads((prev) => + prev.map((u) => + u.id === item.id + ? { ...u, fileId: found.id, serverStatus: found.processing_status.status } + : u + ) + ); + } + } + } catch (err) { + // Network errors are transient – silently retry on the next tick. + console.debug("[StatusPoll] failed:", err); + } + } + }; + + const interval = setInterval(poll, 5000); + return () => clearInterval(interval); + }, []); // Intentionally empty – poll() reads state via uploadsRef + + // --------------------------------------------------------------------------- + // Handle files received from the iOS Share Sheet / Android Share Intent + // --------------------------------------------------------------------------- + useEffect(() => { + if (pendingFiles.length === 0 || !isAuthenticated) return; + const files = [...pendingFiles]; + clearPendingFiles(); + files.forEach((file) => { + void uploadFile(file.uri, file.filename, file.mimeType); + }); + }, [pendingFiles, isAuthenticated, clearPendingFiles, uploadFile]); async function handleCamera() { const { status } = await ImagePicker.requestCameraPermissionsAsync(); @@ -154,16 +235,28 @@ export default function UploadScreen() { } function UploadRow({ item }: { item: UploadItem }) { - const icons: Record = { + const uploadIcons: Record = { pending: "⏳", uploading: "⬆️", done: "✅", error: "❌", }; + /** Human-readable label for the server-side processing status. */ + function serverStatusLabel(s: string): string { + const labels: Record = { + pending: "Queued for processing…", + processing: "Processing…", + completed: "Processed ✓", + failed: "Processing failed", + duplicate: "Duplicate – already processed", + }; + return labels[s] ?? s; + } + return ( - {icons[item.status]} + {uploadIcons[item.status]} {item.filename} @@ -171,8 +264,21 @@ function UploadRow({ item }: { item: UploadItem }) { {item.status === "uploading" && ( )} - {item.status === "done" && ( - Queued for processing + {item.status === "done" && !item.serverStatus && ( + Queued for processing… + )} + {item.status === "done" && item.serverStatus && ( + + {serverStatusLabel(item.serverStatus)} + )} {item.status === "error" && ( {item.error} @@ -254,5 +360,6 @@ const rowStyles = StyleSheet.create({ marginBottom: 4, }, statusDone: { fontSize: 12, color: "#059669" }, + statusQueued: { fontSize: 12, color: "#6b7280" }, statusError: { fontSize: 12, color: "#dc2626" }, }); diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts index 0cb12520..1853422a 100644 --- a/mobile/src/services/api.ts +++ b/mobile/src/services/api.ts @@ -41,21 +41,27 @@ export interface DeviceRegistration { platform: "ios" | "android" | "web"; } +export interface ProcessingStatus { + status: string; + last_step: string | null; + has_errors: boolean; + total_steps: number; +} + export interface FileRecord { id: number; - filename: string; - status: string; - created_at: string; + original_filename: string; file_size: number | null; - content_type: string | null; - owner_id: string | null; + mime_type: string | null; + created_at: string; + processing_status: ProcessingStatus; } export interface UploadResponse { task_id: string; status: string; - message: string; - filename: string; + original_filename: string; + stored_filename: string; } // --------------------------------------------------------------------------- @@ -186,12 +192,21 @@ class DocuElevateAPI { return this.request("POST", "/api/ui-upload", { formData }); } - /** List recently processed files. */ - async listFiles(page = 1, pageSize = 20): Promise { - return this.request( + /** List recently processed files, optionally filtered by filename search. */ + async listFiles(page = 1, pageSize = 20, search?: string): Promise { + let url = `/api/files?page=${page}&per_page=${pageSize}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + const data = await this.request<{ files: FileRecord[]; pagination: unknown }>("GET", url); + return data.files; + } + + /** Get the processing status for a single file by its ID. */ + async getFileStatus(fileId: number): Promise { + const data = await this.request<{ processing_status: ProcessingStatus }>( "GET", - `/api/files?page=${page}&page_size=${pageSize}` + `/api/files/${fileId}` ); + return data.processing_status; } }