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>
This commit is contained in:
+4
-3
@@ -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`).
|
||||
|
||||
|
||||
+20
-2
@@ -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) });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Text style={styles.actionLabel}>Camera</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.photoLibraryButton]}
|
||||
onPress={handlePhotoLibrary}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select photo from library"
|
||||
>
|
||||
<Text style={styles.actionIcon}>🖼️</Text>
|
||||
<Text style={styles.actionLabel}>Photos</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.fileButton]}
|
||||
onPress={handleFilePicker}
|
||||
@@ -208,7 +242,7 @@ export default function UploadScreen() {
|
||||
accessibilityLabel="Pick file from device"
|
||||
>
|
||||
<Text style={styles.actionIcon}>📄</Text>
|
||||
<Text style={styles.actionLabel}>File Picker</Text>
|
||||
<Text style={styles.actionLabel}>Files</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -218,7 +252,7 @@ export default function UploadScreen() {
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>☁️</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Tap Camera or File Picker to upload a document.
|
||||
Tap Camera, Photos, or Files to upload a document.
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user