fix(mobile): iOS share sheet, empty files tab, and stuck upload status

- 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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-16 15:52:54 +00:00
parent a5ef7e4903
commit 4de6b439ce
8 changed files with 341 additions and 50 deletions
+17 -2
View File
@@ -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=<filename>` 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/
+24 -8
View File
@@ -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 <api_token>` on all requests.
+33 -1
View File
@@ -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": {
+57 -3
View File
@@ -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 (
<SafeAreaProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</SafeAreaProvider>
);
}
+51
View File
@@ -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<ShareContextValue>({
pendingFiles: [],
addPendingFile: () => {},
clearPendingFiles: () => {},
});
export function ShareProvider({ children }: { children: React.ReactNode }) {
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
const addPendingFile = useCallback((file: SharedFile) => {
setPendingFiles((prev) => [...prev, file]);
}, []);
const clearPendingFiles = useCallback(() => {
setPendingFiles([]);
}, []);
return (
<ShareContext.Provider value={{ pendingFiles, addPendingFile, clearPendingFiles }}>
{children}
</ShareContext.Provider>
);
}
export function useShare(): ShareContextValue {
return useContext(ShareContext);
}
+7 -6
View File
@@ -36,11 +36,11 @@ function formatDate(iso: string): string {
function statusEmoji(status: string): string {
const map: Record<string, string> = {
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 (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(file.status)}</Text>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.filename}
{file.original_filename}
</Text>
<Text style={rowStyles.meta}>
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<Text style={rowStyles.status}>{file.status}</Text>
<Text style={rowStyles.status}>{status}</Text>
</View>
);
}
+126 -19
View File
@@ -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<UploadItem[]>([]);
function updateItem(id: string, patch: Partial<UploadItem>) {
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<UploadItem[]>([]);
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<UploadItem["status"], string> = {
const uploadIcons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
};
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record<string, string> = {
pending: "Queued for processing…",
processing: "Processing…",
completed: "Processed ✓",
failed: "Processing failed",
duplicate: "Duplicate already processed",
};
return labels[s] ?? s;
}
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{icons[item.status]}</Text>
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
@@ -171,8 +264,21 @@ function UploadRow({ item }: { item: UploadItem }) {
{item.status === "uploading" && (
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && (
<Text style={rowStyles.statusDone}>Queued for processing</Text>
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>Queued for processing</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
style={
item.serverStatus === "completed"
? rowStyles.statusDone
: item.serverStatus === "failed"
? rowStyles.statusError
: rowStyles.statusQueued
}
>
{serverStatusLabel(item.serverStatus)}
</Text>
)}
{item.status === "error" && (
<Text style={rowStyles.statusError}>{item.error}</Text>
@@ -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" },
});
+26 -11
View File
@@ -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<UploadResponse>("POST", "/api/ui-upload", { formData });
}
/** List recently processed files. */
async listFiles(page = 1, pageSize = 20): Promise<FileRecord[]> {
return this.request<FileRecord[]>(
/** List recently processed files, optionally filtered by filename search. */
async listFiles(page = 1, pageSize = 20, search?: string): Promise<FileRecord[]> {
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<ProcessingStatus> {
const data = await this.request<{ processing_status: ProcessingStatus }>(
"GET",
`/api/files?page=${page}&page_size=${pageSize}`
`/api/files/${fileId}`
);
return data.processing_status;
}
}