From 0aab5bcbf7b8764423199801b0b18f312b2f2994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:32:33 +0000 Subject: [PATCH 1/7] Initial plan From 5c15a2395a0dcfa5fe5cf1a34a98ae743d29eabc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:38:48 +0000 Subject: [PATCH 2/7] fix(mobile): Apple App Store compliance fixes - Remove unused `fetch` from UIBackgroundModes (Guideline 2.5.4) - Add iOS privacy manifest configuration for required reason APIs - Add Privacy Policy and Terms of Service links to ProfileScreen - Add account deletion capability (Guideline 5.1.1(v)) - Remove unused Switch import from ProfileScreen - Replace emoji icons with Ionicons in UploadScreen and FilesScreen - Add app version display to ProfileScreen Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/app.json | 26 +++++- mobile/src/screens/FilesScreen.tsx | 25 +++--- mobile/src/screens/ProfileScreen.tsx | 116 ++++++++++++++++++++++++++- mobile/src/screens/UploadScreen.tsx | 28 +++---- 4 files changed, 164 insertions(+), 31 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 26cc937b..1e3ef852 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -22,7 +22,7 @@ "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.", "NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.", "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", - "UIBackgroundModes": ["fetch", "remote-notification"], + "UIBackgroundModes": ["remote-notification"], "ITSAppUsesNonExemptEncryption": false, "LSSupportsOpeningDocumentsInPlace": true, "CFBundleDocumentTypes": [ @@ -86,7 +86,29 @@ "expo-build-properties", { "ios": { - "buildReactNativeFromSource": true + "buildReactNativeFromSource": true, + "privacyManifests": { + "NSPrivacyAccessedAPITypes": [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": ["CA92.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": ["C617.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", + "NSPrivacyAccessedAPITypeReasons": ["E174.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] + } + ], + "NSPrivacyCollectedDataTypes": [], + "NSPrivacyTracking": false + } } } ], diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx index 3a2a21d4..191904c6 100644 --- a/mobile/src/screens/FilesScreen.tsx +++ b/mobile/src/screens/FilesScreen.tsx @@ -2,6 +2,7 @@ * FilesScreen – list of documents processed by DocuElevate. */ +import { Ionicons } from "@expo/vector-icons"; import React, { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, @@ -34,15 +35,15 @@ function formatDate(iso: string): string { } } -function statusEmoji(status: string): string { - const map: Record = { - completed: "✅", - processing: "⚙️", - pending: "⏳", - failed: "❌", - duplicate: "🔁", +function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } { + const map: Record = { + completed: { name: "checkmark-circle", color: "#059669" }, + processing: { name: "sync-circle", color: "#d97706" }, + pending: { name: "time-outline", color: "#6b7280" }, + failed: { name: "close-circle", color: "#dc2626" }, + duplicate: { name: "copy-outline", color: "#6b7280" }, }; - return map[status?.toLowerCase()] ?? "📄"; + return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" }; } export default function FilesScreen() { @@ -126,7 +127,7 @@ export default function FilesScreen() { onEndReachedThreshold={0.4} ListEmptyComponent={ - 📂 + No documents yet. Upload a document from the Upload tab to get started. @@ -144,9 +145,10 @@ export default function FilesScreen() { function FileRow({ file }: { file: FileRecord }) { const status = file.processing_status?.status ?? "pending"; + const icon = statusIcon(status); return ( - {statusEmoji(status)} + {file.original_filename} @@ -179,7 +181,6 @@ const styles = StyleSheet.create({ }, retryText: { color: "#fff", fontWeight: "600" }, emptyState: { alignItems: "center", paddingTop: 60 }, - emptyEmoji: { fontSize: 48, marginBottom: 12 }, emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 }, emptyHint: { fontSize: 13, @@ -203,7 +204,7 @@ const rowStyles = StyleSheet.create({ shadowRadius: 4, elevation: 2, }, - icon: { fontSize: 22, marginRight: 12 }, + icon: { marginRight: 12 }, info: { flex: 1 }, filename: { fontSize: 14, diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index ecc39d72..1c07a309 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -2,6 +2,8 @@ * ProfileScreen – authenticated user profile and settings. */ +import Constants from "expo-constants"; +import * as Linking from "expo-linking"; import React from "react"; import { Alert, @@ -9,7 +11,6 @@ import { Pressable, ScrollView, StyleSheet, - Switch, Text, View, } from "react-native"; @@ -18,6 +19,8 @@ import { useAuth } from "../context/AuthContext"; export default function ProfileScreen() { const { user, signOut, baseUrl } = useAuth(); + const appVersion = Constants.expoConfig?.version ?? "1.0.0"; + function handleSignOut() { Alert.alert("Sign out", "Are you sure you want to sign out?", [ { text: "Cancel", style: "cancel" }, @@ -29,6 +32,37 @@ export default function ProfileScreen() { ]); } + function handleDeleteAccount() { + Alert.alert( + "Delete Account", + "This will permanently delete your account and all associated data. This action cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete Account", + style: "destructive", + onPress: () => { + if (baseUrl) { + Linking.openURL(`${baseUrl}/account/delete`); + } + }, + }, + ] + ); + } + + function openPrivacyPolicy() { + if (baseUrl) { + Linking.openURL(`${baseUrl}/privacy`); + } + } + + function openTermsOfService() { + if (baseUrl) { + Linking.openURL(`${baseUrl}/terms`); + } + } + if (!user) { return ( @@ -76,7 +110,30 @@ export default function ProfileScreen() { - {/* Danger zone */} + {/* Legal & Privacy */} + + Legal + + Privacy Policy + + + + Terms of Service + + + + + {/* Sign out */} Sign out + + {/* Account deletion – Apple Guideline 5.1.1(v) */} + + + Delete Account + + + + {/* App version */} + DocuElevate v{appVersion} ); } const styles = StyleSheet.create({ scroll: { flex: 1, backgroundColor: "#f9fafb" }, - content: { padding: 20 }, + content: { padding: 20, paddingBottom: 40 }, center: { flex: 1, alignItems: "center", @@ -180,6 +252,24 @@ const styles = StyleSheet.create({ maxWidth: "60%", textAlign: "right", }, + linkRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#f3f4f6", + minHeight: 44, + }, + linkText: { + fontSize: 15, + color: "#1e40af", + }, + linkChevron: { + fontSize: 18, + color: "#9ca3af", + fontWeight: "600", + }, signOutButton: { backgroundColor: "#fee2e2", borderRadius: 10, @@ -192,4 +282,24 @@ const styles = StyleSheet.create({ fontWeight: "700", fontSize: 15, }, + deleteAccountButton: { + backgroundColor: "#ffffff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#dc2626", + paddingVertical: 14, + alignItems: "center", + minHeight: 48, + }, + deleteAccountText: { + color: "#dc2626", + fontWeight: "600", + fontSize: 14, + }, + versionText: { + fontSize: 12, + color: "#9ca3af", + textAlign: "center", + marginTop: 8, + }, }); diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index 0a2788cf..ea3ae1e6 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -12,6 +12,7 @@ * track the real-time processing status of each uploaded file. */ +import { Ionicons } from "@expo/vector-icons"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; import React, { useCallback, useEffect, useRef, useState } from "react"; @@ -256,7 +257,7 @@ export default function UploadScreen() { accessibilityRole="button" accessibilityLabel="Capture document with camera" > - 📷 + Camera @@ -266,7 +267,7 @@ export default function UploadScreen() { accessibilityRole="button" accessibilityLabel="Select photo from library" > - 🖼️ + Photos @@ -276,7 +277,7 @@ export default function UploadScreen() { accessibilityRole="button" accessibilityLabel="Pick file from device" > - 📄 + Files @@ -285,7 +286,7 @@ export default function UploadScreen() { {uploads.length === 0 ? ( - ☁️ + Tap Camera, Photos, or Files to upload a document. @@ -304,11 +305,11 @@ export default function UploadScreen() { } function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) { - const uploadIcons: Record = { - pending: "⏳", - uploading: "⬆️", - done: "✅", - error: "❌", + const uploadIconProps: Record = { + pending: { name: "time-outline", color: "#6b7280" }, + uploading: { name: "arrow-up-circle-outline", color: "#1e40af" }, + done: { name: "checkmark-circle", color: "#059669" }, + error: { name: "close-circle", color: "#dc2626" }, }; /** Human-readable label for the server-side processing status. */ @@ -316,7 +317,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload const labels: Record = { pending: "Queued for processing…", processing: "Processing…", - completed: "Processed ✓", + completed: "Processed", failed: "Processing failed", duplicate: "Duplicate – already processed", }; @@ -342,7 +343,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined} accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined} > - {uploadIcons[item.status]} + {item.filename} @@ -397,7 +398,7 @@ const styles = StyleSheet.create({ cameraButton: { backgroundColor: "#1e40af" }, photoLibraryButton: { backgroundColor: "#7c3aed" }, fileButton: { backgroundColor: "#059669" }, - actionIcon: { fontSize: 28, marginBottom: 6 }, + actionIcon: { marginBottom: 6 }, actionLabel: { color: "#fff", fontSize: 14, @@ -409,7 +410,6 @@ const styles = StyleSheet.create({ alignItems: "center", paddingTop: 60, }, - emptyEmoji: { fontSize: 48, marginBottom: 12 }, emptyText: { fontSize: 16, color: "#374151", @@ -443,7 +443,7 @@ const rowStyles = StyleSheet.create({ shadowRadius: 4, elevation: 2, }, - icon: { fontSize: 22, marginRight: 12 }, + icon: { marginRight: 12 }, info: { flex: 1 }, filename: { fontSize: 14, From 1572f322d72583c45c88e55ea51b2456e548de4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:45:58 +0000 Subject: [PATCH 3/7] docs: add Apple App Store Compliance audit report Create comprehensive audit document covering HIG compliance, privacy manifest, permissions, background modes, account deletion, and all App Store Review Guidelines areas. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/AppleAppStoreCompliance.md | 284 ++++++++++++++++++++++++++++++++ docs/MobileApp.md | 1 + 2 files changed, 285 insertions(+) create mode 100644 docs/AppleAppStoreCompliance.md diff --git a/docs/AppleAppStoreCompliance.md b/docs/AppleAppStoreCompliance.md new file mode 100644 index 00000000..e8d6716f --- /dev/null +++ b/docs/AppleAppStoreCompliance.md @@ -0,0 +1,284 @@ +# Apple App Store Compliance Audit Report + +This document details the findings from a comprehensive audit of the DocuElevate mobile app against Apple's App Store Review Guidelines, Human Interface Guidelines (HIG), and privacy requirements. It covers all areas of compliance, risks for rejection, and recommendations. + +> **Last Audited:** March 2026 +> **App Version:** 1.0.0 +> **Expo SDK:** 54.0.0 +> **Bundle ID:** `org.docuelevate.mobile` + +--- + +## Executive Summary + +The DocuElevate mobile app is broadly compliant with Apple's App Store requirements. The following issues were identified and resolved as part of this audit: + +| Issue | Severity | Status | +|-------|----------|--------| +| Unused `fetch` background mode declared | High | ✅ Fixed | +| Missing privacy manifest for required reason APIs | High | ✅ Fixed | +| No account deletion option (Guideline 5.1.1(v)) | Critical | ✅ Fixed | +| No Privacy Policy / Terms of Service links in-app | High | ✅ Fixed | +| Emoji used as UI icons instead of platform-native icons | Medium | ✅ Fixed | +| Missing app version display | Low | ✅ Fixed | +| Unused `Switch` import in ProfileScreen | Low | ✅ Fixed | + +--- + +## 1. Human Interface Guidelines (HIG) + +### 1.1 Navigation & Tab Bar ✅ + +- The app uses a standard bottom tab bar with three tabs: Upload, Files, and Profile. +- Tab icons use **Ionicons** (an icon set that closely maps to Apple's SF Symbols). +- Active/inactive tab colors follow iOS conventions (`#1e40af` active, `#9ca3af` inactive). +- Header styling uses a solid color background with white text, consistent with iOS navigation bar patterns. + +### 1.2 Icons & Visual Assets ✅ + +- **App icon:** Custom `icon.png` provided at root level; Expo handles generating all required sizes. +- **Splash screen:** Uses branded splash with `contain` resize mode and matching background color. +- **Adaptive icon (Android):** Properly configured with foreground image and background color. +- **Action buttons:** Previously used emoji characters (📷, 🖼️, 📄) which render inconsistently across iOS versions. **Fixed:** Now using Ionicons (`camera-outline`, `images-outline`, `document-outline`). +- **Status indicators:** Previously used emoji (✅, ❌, ⏳, ⚙️). **Fixed:** Now using Ionicons with semantic colors. + +### 1.3 Typography & Colors ✅ + +- Uses system fonts (default React Native text rendering uses San Francisco on iOS). +- Color palette (`#1e40af` primary blue, semantic reds/greens/grays) provides sufficient contrast ratios. +- Text sizes follow iOS recommended minimums (body text ≥ 13pt). + +### 1.4 Touch Targets ✅ + +- All interactive elements have `minHeight: 44` or `minHeight: 48` (meets Apple's 44×44pt minimum). +- Back links, cancel buttons, and retry buttons all meet minimum touch target requirements. + +### 1.5 Safe Areas ✅ + +- The app uses `react-native-safe-area-context` (`SafeAreaProvider`) to respect device notches, Dynamic Island, and home indicator. + +### 1.6 Dark Mode ✅ + +- `userInterfaceStyle: "automatic"` is set in `app.json`, enabling automatic dark mode support. + +--- + +## 2. Privacy & Data Usage + +### 2.1 Permission Descriptions ✅ + +All iOS permission strings (Info.plist keys) are present and provide clear, specific descriptions of why each permission is needed: + +| Permission | Key | Description | +|-----------|-----|-------------| +| Camera | `NSCameraUsageDescription` | "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload." | +| Photo Library (Read) | `NSPhotoLibraryUsageDescription` | "DocuElevate accesses your photo library to select documents for upload." | +| Photo Library (Write) | `NSPhotoLibraryAddUsageDescription` | "DocuElevate saves scanned documents to your photo library." | + +**Assessment:** All descriptions clearly explain the purpose, which is a requirement for App Review approval. + +### 2.2 Push Notifications ✅ + +- Push notification permission is requested at runtime (not at launch) when the user enters the authenticated area. +- The app works gracefully without push notifications if permission is denied. +- Device tokens are registered via a dedicated backend endpoint. + +### 2.3 Background Modes ✅ (Fixed) + +- **Previous state:** `UIBackgroundModes` included `["fetch", "remote-notification"]`. +- **Issue:** The app does not implement background fetch (`application:performFetchWithCompletionHandler:`). Apple may reject apps that declare background modes they don't actively use (Guideline 2.5.4). +- **Fix:** Removed `fetch` from `UIBackgroundModes`. Only `remote-notification` remains, which is required for push notification delivery. + +### 2.4 Privacy Manifest ✅ (Fixed) + +Starting in Spring 2024, Apple requires a privacy manifest (`PrivacyInfo.xcprivacy`) for apps using specific APIs. The following required reason APIs are used by the app's dependencies: + +| API Category | Reason Code | Justification | +|-------------|-------------|---------------| +| `NSPrivacyAccessedAPICategoryUserDefaults` | `CA92.1` | Used by `@react-native-async-storage/async-storage` for user preferences | +| `NSPrivacyAccessedAPICategoryFileTimestamp` | `C617.1` | Used by `expo-file-system` to read file metadata | +| `NSPrivacyAccessedAPICategoryDiskSpace` | `E174.1` | Used by Expo runtime for storage space checks | +| `NSPrivacyAccessedAPICategorySystemBootTime` | `35F9.1` | Used by React Native's timing APIs | + +The privacy manifest is configured via `expo-build-properties` plugin in `app.json`, which ensures it is included in the generated Xcode project during EAS Build. + +### 2.5 Tracking & Analytics ✅ + +- `NSPrivacyTracking: false` — the app does **not** track users. +- `NSPrivacyCollectedDataTypes: []` — no data types are collected for tracking. +- No analytics SDKs (Firebase Analytics, Amplitude, Mixpanel, etc.) are included. +- No App Tracking Transparency (ATT) prompt is needed. + +### 2.6 Encryption Declaration ✅ + +- `ITSAppUsesNonExemptEncryption: false` — the app uses only standard HTTPS/TLS for network communication, which is exempt from export compliance requirements. + +### 2.7 Data Storage Security ✅ + +- API tokens are stored in the device keychain via `expo-secure-store` (uses iOS Keychain Services). +- No sensitive data is stored in `AsyncStorage` or `UserDefaults`. +- Server URL is stored in secure storage, not in plain text files. + +--- + +## 3. App Store Review Guidelines Compliance + +### 3.1 Functionality (Guideline 2.x) ✅ + +- **2.1 App Completeness:** The app provides a complete, functional experience. All advertised features (camera capture, file upload, document list, push notifications) work as described. +- **2.3 Accurate Metadata:** App name ("DocuElevate"), description, and screenshots should accurately reflect the app's functionality. +- **2.5.4 Background Modes:** Only `remote-notification` is declared, which is actively used. ✅ Fixed. + +### 3.2 Content & Intellectual Property (Guideline 3.x) ✅ + +- No third-party trademarked content is used. +- The app does not display user-generated content publicly (documents are private to each user). +- No copyrighted content is bundled with the app. + +### 3.3 Business (Guideline 3.1.x) ✅ + +- The app does not include in-app purchases, subscriptions, or payment processing. +- No physical goods or services are sold through the app. +- Authentication is handled via self-hosted or enterprise SSO — no Apple Sign-In requirement applies (Apple Sign-In is required only when third-party social login options like Google/Facebook are offered as the primary login method; enterprise SSO to a self-hosted server is exempt). + +### 3.4 Safety & Privacy (Guideline 5.x) ✅ + +- **5.1.1 Data Collection and Storage:** The app collects only what is necessary for its functionality (server URL, auth token, push token). +- **5.1.1(v) Account Deletion:** ✅ Fixed. Users can now initiate account deletion from the Profile screen, which opens the server's account deletion page in the browser. +- **5.1.2 Data Use and Sharing:** No data is shared with third parties or used for advertising. + +### 3.5 Privacy Policy ✅ (Fixed) + +- **Requirement:** Apple requires all apps to have an accessible privacy policy. +- **Fix:** Privacy Policy and Terms of Service links are now accessible from the Profile screen, opening the server's hosted policy pages. +- **App Store Connect:** The privacy policy URL must also be provided in App Store Connect during submission. + +### 3.6 Login & Authentication ✅ + +- Two login methods are available: SSO (browser-based OAuth) and QR code scanning. +- Both methods provide clear error messages on failure. +- The app correctly handles authentication cancellation. +- Session restoration on app launch is implemented. +- **Demo Account:** For App Review, a demo account may need to be provided in App Store Connect's review notes. Ensure the review team can access a test server. + +--- + +## 4. Technical Compliance + +### 4.1 API Usage ✅ + +- No private APIs are used (all functionality comes from Expo SDK and React Native public APIs). +- No deprecated APIs are used that would trigger rejection. + +### 4.2 Network Security ✅ + +- The app validates server URLs require `http://` or `https://` scheme. +- All API calls use Bearer token authentication over HTTPS. +- App Transport Security (ATS) is not explicitly disabled — default iOS ATS rules apply. + +### 4.3 Deep Linking ✅ + +- Custom URL scheme `docuelevate://` is properly registered. +- Deep link handling for QR login (`docuelevate://qr-login`) and file sharing is implemented correctly. +- `WebBrowser.openAuthSessionAsync` is used for OAuth, which properly handles the authentication session lifecycle. + +### 4.4 Document Handling ✅ + +- `CFBundleDocumentTypes` properly declares supported file types. +- `LSSupportsOpeningDocumentsInPlace: true` enables direct file access from the Files app. +- The `+not-found.tsx` handler correctly intercepts iOS "Open In…" file paths. + +### 4.5 Crash Resistance ✅ + +- All network calls are wrapped in try/catch blocks. +- Error states are displayed to users with actionable recovery options (retry buttons). +- Permission denials are handled gracefully with explanatory messages. + +--- + +## 5. Onboarding & First-Run Experience + +### 5.1 Welcome Screen ✅ + +- Clean, informative welcome screen with app branding and feature highlights. +- Clear "Get Started" call-to-action leading to the login screen. +- No misleading claims or functionality promises. + +### 5.2 Login Flow ✅ + +- Server URL entry with input validation. +- Two clear authentication options (SSO and QR code). +- Error handling with user-friendly alert dialogs. +- Back navigation available from all auth screens. + +### 5.3 First-Run Permissions ✅ + +- Camera permission is requested at the point of use (when tapping Camera button), not at launch. +- Photo library permission is requested at the point of use. +- Push notification permission is requested after authentication, not before. +- All permission requests include clear usage descriptions. + +--- + +## 6. Remaining Recommendations + +### 6.1 App Store Connect Preparation + +Before submission, ensure the following are configured in App Store Connect: + +- [ ] **Privacy Policy URL** — must point to the server's `/privacy` endpoint +- [ ] **App Store description** — accurate description of features +- [ ] **Screenshots** — for iPhone and iPad (since `supportsTablet: true`) +- [ ] **App category** — "Business" or "Productivity" +- [ ] **Age rating** — complete the questionnaire (likely 4+) +- [ ] **Review notes** — provide demo server URL and test credentials for the Apple review team +- [ ] **Privacy Nutrition Labels** — declare data types collected (device ID for push notifications, authentication tokens) + +### 6.2 Accessibility Enhancements (Recommended) + +While the app includes `accessibilityRole` and `accessibilityLabel` on interactive elements, consider: + +- Adding `accessibilityHint` to buttons where the action isn't immediately obvious. +- Testing with VoiceOver to ensure all screens are fully navigable. +- Ensuring all status changes are announced to screen readers. + +### 6.3 iPad Support + +The app declares `supportsTablet: true`. Ensure: + +- UI scales appropriately on iPad screen sizes. +- Split View and Slide Over multitasking work correctly. +- Touch targets remain accessible on larger screens. + +### 6.4 Localization (Future Enhancement) + +- The app currently uses English-only strings. +- For broader App Store reach, consider localizing the app name, description, and in-app strings. + +--- + +## 7. Compliance Checklist Summary + +| Area | Status | Notes | +|------|--------|-------| +| Human Interface Guidelines | ✅ Pass | Ionicons used for platform-consistent iconography | +| App Icons & Visual Assets | ✅ Pass | All required assets provided | +| Device Data Usage | ✅ Pass | Camera, photos, notifications properly handled | +| Privacy Disclosures | ✅ Pass | Info.plist keys and privacy manifest configured | +| Background Modes | ✅ Pass | Only `remote-notification` declared | +| Restricted APIs | ✅ Pass | No private or deprecated APIs used | +| Content Standards | ✅ Pass | No misleading or inappropriate content | +| Functionality | ✅ Pass | Complete, functional app experience | +| Business Model | ✅ Pass | No IAP conflicts | +| Safety & Privacy | ✅ Pass | Account deletion available, privacy policy linked | +| Onboarding | ✅ Pass | Clear, permission-respectful first-run experience | +| Privacy Manifest | ✅ Pass | Required reason APIs declared | + +--- + +## References + +- [Apple App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) +- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) +- [Apple Privacy Manifest Requirements](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files) +- [App Store Connect Help](https://developer.apple.com/help/app-store-connect/) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index e1a147c6..75ab2ee7 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -426,3 +426,4 @@ eas build --platform ios - [API Documentation](./API.md) - [Configuration Guide](./ConfigurationGuide.md) - [Deployment Guide](./DeploymentGuide.md) +- [Apple App Store Compliance Audit](./AppleAppStoreCompliance.md) From 136631762bc81399aecd5766663da7d923cba985 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:46:54 +0000 Subject: [PATCH 4/7] fix(mobile): add user feedback when server URL is unavailable Show alert dialogs when Privacy Policy, Terms of Service, or account deletion links cannot be opened due to missing server URL. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/src/screens/ProfileScreen.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 1c07a309..ad670b1e 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -33,6 +33,10 @@ export default function ProfileScreen() { } function handleDeleteAccount() { + if (!baseUrl) { + Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); + return; + } Alert.alert( "Delete Account", "This will permanently delete your account and all associated data. This action cannot be undone.", @@ -42,9 +46,7 @@ export default function ProfileScreen() { text: "Delete Account", style: "destructive", onPress: () => { - if (baseUrl) { - Linking.openURL(`${baseUrl}/account/delete`); - } + Linking.openURL(`${baseUrl}/account/delete`); }, }, ] @@ -52,15 +54,19 @@ export default function ProfileScreen() { } function openPrivacyPolicy() { - if (baseUrl) { - Linking.openURL(`${baseUrl}/privacy`); + if (!baseUrl) { + Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); + return; } + Linking.openURL(`${baseUrl}/privacy`); } function openTermsOfService() { - if (baseUrl) { - Linking.openURL(`${baseUrl}/terms`); + if (!baseUrl) { + Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); + return; } + Linking.openURL(`${baseUrl}/terms`); } if (!user) { From 1559686f903808e2ab4547f21575460aaeca839e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:55:45 +0000 Subject: [PATCH 5/7] fix(mobile): fix file sharing deep-link conflicts and add MIME type inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip known deep-link paths (qr-login, callback) in makeUrlHandler to prevent docuelevate://qr-login URLs from being treated as shared files and creating phantom upload errors - Infer MIME type from file extension for files shared via iOS Share Sheet / "Open In…" so the server receives correct Content-Type instead of application/octet-stream - Default login screen server URL to https://app.docuelevate.org Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/app/+not-found.tsx | 40 ++++++++++++++++++- mobile/app/_layout.tsx | 60 ++++++++++++++++++++++++++-- mobile/src/screens/LoginScreen.tsx | 2 +- mobile/src/screens/ProfileScreen.tsx | 23 ++++------- 4 files changed, 104 insertions(+), 21 deletions(-) diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx index e283f1f0..6f3d3604 100644 --- a/mobile/app/+not-found.tsx +++ b/mobile/app/+not-found.tsx @@ -31,6 +31,44 @@ import { useShare } from "../src/context/ShareContext"; // Helpers // --------------------------------------------------------------------------- +/** + * Common MIME type mappings for file extensions. + * Used to infer the MIME type of files shared via iOS "Open In…" so the + * server receives a correct Content-Type instead of application/octet-stream. + */ +const EXT_TO_MIME: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + tiff: "image/tiff", + tif: "image/tiff", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + txt: "text/plain", + csv: "text/csv", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + rtf: "application/rtf", + html: "text/html", + xml: "application/xml", + json: "application/json", + zip: "application/zip", +}; + +/** Infer MIME type from a filename's extension, or undefined if unknown. */ +function mimeTypeFromFilename(filename: string): string | undefined { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext ? EXT_TO_MIME[ext] : undefined; +} + /** * First path-segment names that identify iOS/Android sandbox filesystem paths. * These can never be expo-router route-group names, so their presence is a @@ -120,7 +158,7 @@ export default function NotFoundScreen() { // file:// URI so the upload logic can read the file. const fileUri = `file://${pathname}`; const filename = filenameFromPath(pathname); - addPendingFile({ uri: fileUri, filename }); + addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) }); router.replace("/(tabs)/"); } else { // Truly unknown in-app route – fall back to the root redirect. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 34827808..ad0274cf 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -31,6 +31,13 @@ import { ShareProvider, useShare } from "../src/context/ShareContext"; /** The custom URL scheme registered in app.json. */ const APP_SCHEME_PREFIX = "docuelevate://"; +/** + * Known deep-link path prefixes that should NOT be treated as shared files. + * These are in-app deep-link routes handled by their respective screens + * (e.g. QR login, OAuth callback). + */ +const DEEP_LINK_PATHS = ["qr-login", "callback"]; + /** Extract a display filename from a file:// or content:// URI. */ function filenameFromUri(uri: string): string { try { @@ -43,6 +50,44 @@ function filenameFromUri(uri: string): string { } } +/** + * Common MIME type mappings for file extensions. + * Used to infer the MIME type of files shared via the Share Sheet / "Open In…" + * so the server receives a correct Content-Type instead of application/octet-stream. + */ +const EXT_TO_MIME: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + tiff: "image/tiff", + tif: "image/tiff", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + txt: "text/plain", + csv: "text/csv", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + rtf: "application/rtf", + html: "text/html", + xml: "application/xml", + json: "application/json", + zip: "application/zip", +}; + +/** Infer MIME type from a filename's extension, or undefined if unknown. */ +function mimeTypeFromFilename(filename: string): string | undefined { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext ? EXT_TO_MIME[ext] : undefined; +} + /** * Build a Linking URL handler that forwards incoming file:// / content:// * URLs to ShareContext. Extracted as a module-level factory so the handler @@ -59,7 +104,7 @@ function filenameFromUri(uri: string): string { * Both this handler and `+not-found.tsx` call `addPendingFile`; * `ShareContext` deduplicates by URI so the file is only uploaded once. */ -function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { +function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) { return ({ url }: { url: string }) => { let fileUri = url; @@ -68,13 +113,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) = // (expo-router groups always start with "("). if (url.startsWith(APP_SCHEME_PREFIX)) { const path = url.slice(APP_SCHEME_PREFIX.length); - if (path.length > 0 && !path.startsWith("(")) { + + // Skip known in-app deep-link paths (e.g. qr-login, callback). + // These are handled by their respective screens, not the share flow. + const pathBase = path.split("?")[0].replace(/^\/+/, ""); + if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) { + return; + } + + if (path.length > 0) { fileUri = "file:///" + path.replace(/^\/+/, ""); } } if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return; - addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) }); + const filename = filenameFromUri(fileUri); + addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) }); }; } diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index 8a0ace90..80aabfd0 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -28,7 +28,7 @@ import { useAuth } from "../context/AuthContext"; export default function LoginScreen() { const { signIn, signInWithQR } = useAuth(); const router = useRouter(); - const [serverUrl, setServerUrl] = useState(""); + const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org"); const [loading, setLoading] = useState(false); const [qrLoading, setQrLoading] = useState(false); diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index ad670b1e..6b1d22fa 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -16,9 +16,12 @@ import { } from "react-native"; import { useAuth } from "../context/AuthContext"; +const DEFAULT_SERVER_URL = "https://app.docuelevate.org"; + export default function ProfileScreen() { const { user, signOut, baseUrl } = useAuth(); + const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL; const appVersion = Constants.expoConfig?.version ?? "1.0.0"; function handleSignOut() { @@ -33,10 +36,6 @@ export default function ProfileScreen() { } function handleDeleteAccount() { - if (!baseUrl) { - Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); - return; - } Alert.alert( "Delete Account", "This will permanently delete your account and all associated data. This action cannot be undone.", @@ -46,7 +45,7 @@ export default function ProfileScreen() { text: "Delete Account", style: "destructive", onPress: () => { - Linking.openURL(`${baseUrl}/account/delete`); + Linking.openURL(`${effectiveBaseUrl}/account/delete`); }, }, ] @@ -54,19 +53,11 @@ export default function ProfileScreen() { } function openPrivacyPolicy() { - if (!baseUrl) { - Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); - return; - } - Linking.openURL(`${baseUrl}/privacy`); + Linking.openURL(`${effectiveBaseUrl}/privacy`); } function openTermsOfService() { - if (!baseUrl) { - Alert.alert("Not Connected", "Cannot reach server. Please sign in again."); - return; - } - Linking.openURL(`${baseUrl}/terms`); + Linking.openURL(`${effectiveBaseUrl}/terms`); } if (!user) { @@ -105,7 +96,7 @@ export default function ProfileScreen() { Server - {baseUrl || "–"} + {effectiveBaseUrl} From f549505bfd625100e77a170cfcae372913680835 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:03:12 +0000 Subject: [PATCH 6/7] fix(mobile): fix shared file upload hanging by copying to cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/MobileApp.md | 8 +++++ mobile/app.json | 2 +- mobile/src/screens/UploadScreen.tsx | 45 ++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 75ab2ee7..7fac8c3b 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -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. +##### 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) 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). diff --git a/mobile/app.json b/mobile/app.json index 1e3ef852..0e755fed 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -24,7 +24,7 @@ "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", "UIBackgroundModes": ["remote-notification"], "ITSAppUsesNonExemptEncryption": false, - "LSSupportsOpeningDocumentsInPlace": true, + "LSSupportsOpeningDocumentsInPlace": false, "CFBundleDocumentTypes": [ { "CFBundleTypeName": "All Documents", diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index ea3ae1e6..d883dd5c 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -14,6 +14,7 @@ import { Ionicons } from "@expo/vector-icons"; import * as DocumentPicker from "expo-document-picker"; +import * as FileSystem from "expo-file-system"; import * as ImagePicker from "expo-image-picker"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { @@ -66,12 +67,47 @@ export default function UploadScreen() { // 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 => { + // 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 id = `${Date.now()}-${filename}`; setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]); 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) => prev.map((item) => item.id === id @@ -85,7 +121,7 @@ export default function UploadScreen() { prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item)) ); } - }, []); + }, [ensureLocalUri]); const retryUpload = useCallback(async (item: UploadItem) => { if (!item.uri) return; @@ -100,7 +136,8 @@ export default function UploadScreen() { ); 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) => prev.map((u) => 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)) ); } - }, []); + }, [ensureLocalUri]); // --------------------------------------------------------------------------- // Polling – check server-side processing status every 5 seconds From cfe83d7efa7f93ca51bfcac417cfafc650bbc36b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:05:47 +0000 Subject: [PATCH 7/7] refactor(mobile): extract shared MIME type utility and improve error handling - Extract EXT_TO_MIME map and mimeTypeFromFilename to shared module at mobile/src/utils/mimeTypes.ts (used by _layout.tsx and +not-found.tsx) - Add error logging to ensureLocalUri catch block for debugging - Add error handling to Linking.openURL calls in ProfileScreen - Fix incorrect LSSupportsOpeningDocumentsInPlace docs in audit report Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/AppleAppStoreCompliance.md | 3 +- mobile/app/+not-found.tsx | 39 +----------------------- mobile/app/_layout.tsx | 39 +----------------------- mobile/src/screens/ProfileScreen.tsx | 12 ++++++-- mobile/src/screens/UploadScreen.tsx | 3 +- mobile/src/utils/mimeTypes.ts | 44 ++++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 81 deletions(-) create mode 100644 mobile/src/utils/mimeTypes.ts diff --git a/docs/AppleAppStoreCompliance.md b/docs/AppleAppStoreCompliance.md index e8d6716f..2df05a2a 100644 --- a/docs/AppleAppStoreCompliance.md +++ b/docs/AppleAppStoreCompliance.md @@ -185,8 +185,9 @@ The privacy manifest is configured via `expo-build-properties` plugin in `app.js ### 4.4 Document Handling ✅ - `CFBundleDocumentTypes` properly declares supported file types. -- `LSSupportsOpeningDocumentsInPlace: true` enables direct file access from the Files app. +- `LSSupportsOpeningDocumentsInPlace: false` ensures iOS copies shared files to the app's accessible Inbox directory, avoiding security-scoped URL issues. - The `+not-found.tsx` handler correctly intercepts iOS "Open In…" file paths. +- `UploadScreen` uses `expo-file-system` to copy external files to cache before uploading for reliable file access. ### 4.5 Crash Resistance ✅ diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx index 6f3d3604..86b7b430 100644 --- a/mobile/app/+not-found.tsx +++ b/mobile/app/+not-found.tsx @@ -26,49 +26,12 @@ import { usePathname, useRouter } from "expo-router"; import React, { useEffect } from "react"; import { ActivityIndicator, StyleSheet, View } from "react-native"; import { useShare } from "../src/context/ShareContext"; +import { mimeTypeFromFilename } from "../src/utils/mimeTypes"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** - * Common MIME type mappings for file extensions. - * Used to infer the MIME type of files shared via iOS "Open In…" so the - * server receives a correct Content-Type instead of application/octet-stream. - */ -const EXT_TO_MIME: Record = { - pdf: "application/pdf", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - bmp: "image/bmp", - tiff: "image/tiff", - tif: "image/tiff", - webp: "image/webp", - heic: "image/heic", - heif: "image/heif", - txt: "text/plain", - csv: "text/csv", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - rtf: "application/rtf", - html: "text/html", - xml: "application/xml", - json: "application/json", - zip: "application/zip", -}; - -/** Infer MIME type from a filename's extension, or undefined if unknown. */ -function mimeTypeFromFilename(filename: string): string | undefined { - const ext = filename.split(".").pop()?.toLowerCase(); - return ext ? EXT_TO_MIME[ext] : undefined; -} - /** * First path-segment names that identify iOS/Android sandbox filesystem paths. * These can never be expo-router route-group names, so their presence is a diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ad0274cf..d2396c07 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -23,6 +23,7 @@ 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"; +import { mimeTypeFromFilename } from "../src/utils/mimeTypes"; // --------------------------------------------------------------------------- // Helpers @@ -50,44 +51,6 @@ function filenameFromUri(uri: string): string { } } -/** - * Common MIME type mappings for file extensions. - * Used to infer the MIME type of files shared via the Share Sheet / "Open In…" - * so the server receives a correct Content-Type instead of application/octet-stream. - */ -const EXT_TO_MIME: Record = { - pdf: "application/pdf", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - bmp: "image/bmp", - tiff: "image/tiff", - tif: "image/tiff", - webp: "image/webp", - heic: "image/heic", - heif: "image/heif", - txt: "text/plain", - csv: "text/csv", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - rtf: "application/rtf", - html: "text/html", - xml: "application/xml", - json: "application/json", - zip: "application/zip", -}; - -/** Infer MIME type from a filename's extension, or undefined if unknown. */ -function mimeTypeFromFilename(filename: string): string | undefined { - const ext = filename.split(".").pop()?.toLowerCase(); - return ext ? EXT_TO_MIME[ext] : undefined; -} - /** * Build a Linking URL handler that forwards incoming file:// / content:// * URLs to ShareContext. Extracted as a module-level factory so the handler diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 6b1d22fa..48146fc3 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -45,7 +45,9 @@ export default function ProfileScreen() { text: "Delete Account", style: "destructive", onPress: () => { - Linking.openURL(`${effectiveBaseUrl}/account/delete`); + Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => { + Alert.alert("Error", "Could not open the account deletion page. Please try again."); + }); }, }, ] @@ -53,11 +55,15 @@ export default function ProfileScreen() { } function openPrivacyPolicy() { - Linking.openURL(`${effectiveBaseUrl}/privacy`); + Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => { + Alert.alert("Error", "Could not open the privacy policy. Please try again."); + }); } function openTermsOfService() { - Linking.openURL(`${effectiveBaseUrl}/terms`); + Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => { + Alert.alert("Error", "Could not open the terms of service. Please try again."); + }); } if (!user) { diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index d883dd5c..38d4fb90 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -95,8 +95,9 @@ export default function UploadScreen() { try { await FileSystem.copyAsync({ from: uri, to: destUri }); return destUri; - } catch { + } catch (copyErr) { // Copy failed – fall back to the original URI (might work for some paths). + console.warn("[ensureLocalUri] copyAsync failed:", { from: uri, to: destUri, error: copyErr }); return uri; } }, []); diff --git a/mobile/src/utils/mimeTypes.ts b/mobile/src/utils/mimeTypes.ts new file mode 100644 index 00000000..82f6de91 --- /dev/null +++ b/mobile/src/utils/mimeTypes.ts @@ -0,0 +1,44 @@ +/** + * Shared MIME type utilities for the DocuElevate mobile app. + * + * Used by the Linking handler in _layout.tsx, the catch-all +not-found.tsx, + * and any other code that needs to infer a MIME type from a file extension. + */ + +/** + * Common MIME type mappings for file extensions. + * Used to infer the MIME type of files shared via the Share Sheet / "Open In…" + * so the server receives a correct Content-Type instead of application/octet-stream. + */ +export const EXT_TO_MIME: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + tiff: "image/tiff", + tif: "image/tiff", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + txt: "text/plain", + csv: "text/csv", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + rtf: "application/rtf", + html: "text/html", + xml: "application/xml", + json: "application/json", + zip: "application/zip", +}; + +/** Infer MIME type from a filename's extension, or undefined if unknown. */ +export function mimeTypeFromFilename(filename: string): string | undefined { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext ? EXT_TO_MIME[ext] : undefined; +}