Merge pull request #766 from christianlouis/copilot/audit-docuelevate-app-compliance

fix(mobile): App Store compliance audit, fix shared file uploads, default server URL
This commit is contained in:
Christian Krakau-Louis
2026-03-19 14:16:58 +01:00
committed by GitHub
10 changed files with 572 additions and 42 deletions
+285
View File
@@ -0,0 +1,285 @@
# 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: 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 ✅
- 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/)
+9
View File
@@ -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).
@@ -426,3 +434,4 @@ eas build --platform ios
- [API Documentation](./API.md)
- [Configuration Guide](./ConfigurationGuide.md)
- [Deployment Guide](./DeploymentGuide.md)
- [Apple App Store Compliance Audit](./AppleAppStoreCompliance.md)
+25 -3
View File
@@ -22,9 +22,9 @@
"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,
"LSSupportsOpeningDocumentsInPlace": false,
"CFBundleDocumentTypes": [
{
"CFBundleTypeName": "All Documents",
@@ -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
}
}
}
],
+2 -1
View File
@@ -26,6 +26,7 @@ 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
@@ -120,7 +121,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.
+20 -3
View File
@@ -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
@@ -31,6 +32,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 {
@@ -59,7 +67,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 +76,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) });
};
}
+13 -12
View File
@@ -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<string, string> = {
completed: "✅",
processing: "⚙️",
pending: "⏳",
failed: "❌",
duplicate: "🔁",
function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const map: Record<string, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
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={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}>
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 (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
<Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{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,
+1 -1
View File
@@ -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);
+117 -4
View File
@@ -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,15 +11,19 @@ import {
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} 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() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
@@ -29,6 +35,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: () => {
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
Alert.alert("Error", "Could not open the account deletion page. Please try again.");
});
},
},
]
);
}
function openPrivacyPolicy() {
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
Alert.alert("Error", "Could not open the privacy policy. Please try again.");
});
}
function openTermsOfService() {
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
Alert.alert("Error", "Could not open the terms of service. Please try again.");
});
}
if (!user) {
return (
<View style={styles.center}>
@@ -65,7 +102,7 @@ export default function ProfileScreen() {
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || ""}
{effectiveBaseUrl}
</Text>
</View>
<View style={styles.row}>
@@ -76,7 +113,30 @@ export default function ProfileScreen() {
</View>
</View>
{/* Danger zone */}
{/* Legal & Privacy */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Legal</Text>
<Pressable
style={styles.linkRow}
onPress={openPrivacyPolicy}
accessibilityRole="link"
accessibilityLabel="Privacy Policy"
>
<Text style={styles.linkText}>Privacy Policy</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={styles.linkRow}
onPress={openTermsOfService}
accessibilityRole="link"
accessibilityLabel="Terms of Service"
>
<Text style={styles.linkText}>Terms of Service</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
</View>
{/* Sign out */}
<View style={styles.section}>
<Pressable
style={styles.signOutButton}
@@ -87,13 +147,28 @@ export default function ProfileScreen() {
<Text style={styles.signOutText}>Sign out</Text>
</Pressable>
</View>
{/* Account deletion Apple Guideline 5.1.1(v) */}
<View style={styles.section}>
<Pressable
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
accessibilityLabel="Delete account"
>
<Text style={styles.deleteAccountText}>Delete Account</Text>
</Pressable>
</View>
{/* App version */}
<Text style={styles.versionText}>DocuElevate v{appVersion}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 },
content: { padding: 20, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
@@ -180,6 +255,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 +285,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,
},
});
+56 -18
View File
@@ -12,7 +12,9 @@
* 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 FileSystem from "expo-file-system";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -65,12 +67,48 @@ 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<string> => {
// 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 (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;
}
}, []);
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
@@ -84,7 +122,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;
@@ -99,7 +137,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
@@ -113,7 +152,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
@@ -256,7 +295,7 @@ export default function UploadScreen() {
accessibilityRole="button"
accessibilityLabel="Capture document with camera"
>
<Text style={styles.actionIcon}>📷</Text>
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Camera</Text>
</Pressable>
@@ -266,7 +305,7 @@ export default function UploadScreen() {
accessibilityRole="button"
accessibilityLabel="Select photo from library"
>
<Text style={styles.actionIcon}>🖼</Text>
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Photos</Text>
</Pressable>
@@ -276,7 +315,7 @@ export default function UploadScreen() {
accessibilityRole="button"
accessibilityLabel="Pick file from device"
>
<Text style={styles.actionIcon}>📄</Text>
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Files</Text>
</Pressable>
</View>
@@ -285,7 +324,7 @@ export default function UploadScreen() {
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}></Text>
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document.
</Text>
@@ -304,11 +343,11 @@ export default function UploadScreen() {
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
const uploadIcons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
const uploadIconProps: Record<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
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 +355,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
const labels: Record<string, string> = {
pending: "Queued for processing…",
processing: "Processing…",
completed: "Processed",
completed: "Processed",
failed: "Processing failed",
duplicate: "Duplicate already processed",
};
@@ -342,7 +381,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}
>
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
@@ -397,7 +436,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 +448,6 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
@@ -443,7 +481,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
+44
View File
@@ -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<string, string> = {
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;
}