restore(mobile): restore mobile/ directory to pre-d2217531 state

Restored mobile/ from d22175310a711e7ebdd8062ae29a54f0136dc3f6^
(parent commit d94e9ca4bc).

Commit d22175310a (google-labs-jules[bot], 2026-03-23T14:45:22Z) introduced
an SSRF security fix for IMAP connections but unintentionally deleted or
truncated a large number of files across the repository, including 24 files
under mobile/.

This commit targets only the mobile/ directory and restores the following
files to their pre-d2217531 state:

- mobile/README.md
- mobile/app.json
- mobile/app/(tabs)/_layout.tsx
- mobile/app/(tabs)/file-detail.tsx  (re-added)
- mobile/app/+not-found.tsx          (re-added)
- mobile/app/_layout.tsx
- mobile/eslint.config.js            (re-added)
- mobile/package-lock.json
- mobile/package.json
- mobile/src/context/ShareContext.tsx
- mobile/src/i18n/de.json            (re-added)
- mobile/src/i18n/en.json            (re-added)
- mobile/src/i18n/es.json            (re-added)
- mobile/src/i18n/fr.json            (re-added)
- mobile/src/i18n/index.ts           (re-added)
- mobile/src/i18n/it.json            (re-added)
- mobile/src/screens/FileDetailScreen.tsx (re-added)
- mobile/src/screens/FilesScreen.tsx
- mobile/src/screens/LoginScreen.tsx
- mobile/src/screens/ProfileScreen.tsx
- mobile/src/screens/UploadScreen.tsx
- mobile/src/screens/WelcomeScreen.tsx
- mobile/src/services/api.ts
- mobile/src/utils/mimeTypes.ts      (re-added)
- mobile/src/utils/normalizeUri.ts   (re-added)

Security fixes introduced by d2217531 that are unrelated to mobile/
(IMAP SSRF fix in app/utils/network.py and app/tasks/imap_tasks.py)
are preserved — this restore targets only files under mobile/.
This commit is contained in:
copilot-swe-agent[bot]
2026-03-24 12:12:05 +00:00
parent aca12858c1
commit 2f3c22000c
25 changed files with 2354 additions and 646 deletions
+22 -8
View File
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
import { useAuth } from "../../src/context/AuthContext";
import { useLocale, t } from "../../src/i18n";
export default function TabLayout() {
const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated);
// Subscribe to language changes so tab labels re-render when the language
// is switched. The `lang` variable is intentionally unused its only
// purpose is to make this component a consumer of LocaleContext.
useLocale();
return (
<Tabs
@@ -37,8 +42,8 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: "Upload",
tabBarLabel: "Upload",
title: t("tabs.upload"),
tabBarLabel: t("tabs.upload"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="cloud-upload-outline" size={size} color={color} />
),
@@ -48,23 +53,32 @@ export default function TabLayout() {
<Tabs.Screen
name="files"
options={{
title: "Files",
tabBarLabel: "Files",
title: t("tabs.files"),
tabBarLabel: t("tabs.files"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
headerTitle: "My Documents",
headerTitle: t("files.title"),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarLabel: "Profile",
title: t("tabs.profile"),
tabBarLabel: t("tabs.profile"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-circle-outline" size={size} color={color} />
),
headerTitle: "Profile",
headerTitle: t("tabs.profile"),
}}
/>
{/* File detail screen hidden from tab bar, accessed via navigation */}
<Tabs.Screen
name="file-detail"
options={{
href: null,
title: t("file_detail.title"),
headerTitle: t("file_detail.title"),
}}
/>
</Tabs>
+4
View File
@@ -0,0 +1,4 @@
/**
* File detail route displays processing status and logs for a single file.
*/
export { default } from "../../src/screens/FileDetailScreen";
+154
View File
@@ -0,0 +1,154 @@
/**
* Catch-all "not found" route for expo-router.
*
* This screen intercepts two different situations:
*
* 1. **iOS "Open In…" / share sheet** — iOS delivers files to the app via a
* `docuelevate://<path>` URL. expo-router strips the custom scheme and
* tries to match the raw filesystem path (e.g.
* `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because
* no such route exists, expo-router previously threw "unmatched route
* docuelevate://…" and the upload never happened.
*
* This screen detects the filesystem-path pattern, 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 `_layout.tsx` may also fire for the same URL;
* `ShareContext.addPendingFile` deduplicates by URI so the file is only
* uploaded once.
*
* 2. **Any other unmatched in-app route** — redirect silently to the root so
* the user isn't left on a blank error page.
*/
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
// ---------------------------------------------------------------------------
/**
* 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
* strong positive signal that the URL is a shared file rather than a route.
*
* iOS: /private/var/mobile/… → "private"
* /var/mobile/… → "var" (symlink to /private/var/mobile)
* /tmp/… → "tmp"
* Android: /data/user/0/… → "data"
* /storage/emulated/0/… → "storage"
*/
const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"];
/**
* Route-group / special-file prefixes that identify genuine in-app routes
* rather than filesystem path segments.
*
* ⚠️ Keep this list in sync with the top-level entries in the `app/`
* directory. Add an entry here if you add a new top-level route group
* that does **not** use the parentheses convention.
*/
const IN_APP_ROUTE_PREFIXES = [
"(auth)", // app/(auth)/
"(tabs)", // app/(tabs)/
"_", // expo-router special files (_layout, _sitemap, …)
"+", // expo-router special files (+not-found, …)
"--", // Expo Go development proxy prefix
];
/**
* Return `true` when `pathname` looks like a filesystem path delivered by iOS
* "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a
* legitimate in-app route.
*
* Detection strategy:
* 1. **Positive check** if the first path segment matches a known device
* filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path.
* 2. **Fallback negative check** if the path does not start with any known
* in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file
* path. This is a heuristic but safe because expo-router route groups
* always use parentheses (e.g. `(auth)`, `(tabs)`).
*/
function looksLikeFilePath(pathname: string): boolean {
const stripped = pathname.replace(/^\/+/, "");
if (stripped.length === 0) return false;
// Positive signal: path starts with a known device filesystem root segment.
const firstSegment = stripped.split("/")[0];
if (FS_PATH_ROOTS.includes(firstSegment)) return true;
// Fallback: paths that start with a known in-app route prefix are routes.
return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix));
}
/**
* Extract a display filename from a filesystem path.
* Handles URL-encoded characters and strips query strings.
*/
function filenameFromPath(pathname: string): string {
try {
const decoded = decodeURIComponent(pathname);
const segments = decoded.split("/").filter(Boolean);
const last = segments[segments.length - 1] ?? "shared_file";
return last.split("?")[0] || "shared_file";
} catch {
return "shared_file";
}
}
// ---------------------------------------------------------------------------
// Screen component
// ---------------------------------------------------------------------------
export default function NotFoundScreen() {
const pathname = usePathname();
const router = useRouter();
const { addPendingFile } = useShare();
// Guard: track which pathname has been handled so the effect does not
// re-fire when `router` or `addPendingFile` change identity mid-navigation.
const handledRef = React.useRef<string | null>(null);
useEffect(() => {
if (handledRef.current === pathname) return; // already handled
handledRef.current = pathname;
if (looksLikeFilePath(pathname)) {
// Filesystem path from iOS "Open In…" add the file to ShareContext
// and redirect to the Upload tab. UploadScreen will pick up the
// pending file and begin uploading automatically.
//
// The pathname from expo-router is the raw filesystem path
// (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a
// file:// URI so the upload logic can read the file.
const fileUri = `file://${pathname}`;
const filename = filenameFromPath(pathname);
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
router.replace("/(tabs)/");
} else {
// Truly unknown in-app route fall back to the root redirect.
router.replace("/");
}
}, [pathname, router, addPendingFile]);
// Show a brief spinner while the redirect is in flight.
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
},
});
+54 -10
View File
@@ -9,6 +9,11 @@
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
* file:// / content:// URL is captured and forwarded to UploadScreen via
* ShareContext.
*
* The companion `+not-found.tsx` handles the case where expo-router receives
* a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and
* cannot match it to a route. It adds the file directly to ShareContext and
* redirects to the Upload tab so the file is uploaded transparently.
*/
import * as Linking from "expo-linking";
@@ -18,6 +23,8 @@ 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 { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// ---------------------------------------------------------------------------
// Helpers
@@ -26,6 +33,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,12 +57,18 @@ function filenameFromUri(uri: string): string {
* 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
* On iOS the Share Sheet / "Open In" action may deliver the file path under
* the app's custom URL scheme (`docuelevate://…/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.
*
* Note: expo-router also receives the same URL and will attempt to match it as
* an in-app route. When no route matches it renders `+not-found.tsx`, which
* adds the file to ShareContext directly and redirects to the Upload tab.
* 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;
@@ -57,13 +77,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) });
};
}
@@ -72,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// ---------------------------------------------------------------------------
function AuthGuard() {
const { isLoading, isAuthenticated } = useAuth();
const { isLoading, isAuthenticated, user } = useAuth();
const { addPendingFile } = useShare();
const { setLang } = useLocale();
const segments = useSegments();
const router = useRouter();
// Apply the server-side language preference whenever the user profile is
// loaded (on login or app resume). This syncs the language set on the
// desktop/web client to the mobile app. If the server language is not
// supported by the mobile app, we leave the current language unchanged.
useEffect(() => {
if (user?.preferred_language && isLanguageSupported(user.preferred_language)) {
void setLang(user.preferred_language);
}
}, [user?.preferred_language, setLang]);
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
// Both cold-start (app was not running) and warm-start (app in background)
// cases are handled.
@@ -121,6 +161,8 @@ function AuthGuard() {
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
{/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
<Stack.Screen name="+not-found" />
</Stack>
);
}
@@ -132,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
<SafeAreaProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
<LocaleProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider>
);
}