fix: merge main branch and renumber migration 037→040

Resolve all merge conflicts between our automation feature branch and
current main (v0.163.0, 920 commits ahead).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
  (classification_rules, qr_auth, sessions, system_reset)
- app/config.py: add main's new settings (dropbox_use_global_credentials,
  factory_reset_on_startup, enable_factory_reset)
- app/models.py: add main's new models (ClassificationRuleModel, UserSession,
  QRLoginChallenge, SharePoint integration type)
- app/utils/settings_service.py: merge automation_hooks_enabled with main's
  new metadata entries
- docs/API.md: merge automation API docs with main's classification rules docs
- docs/ConfigurationGuide.md: add factory reset settings
- tests/conftest.py: import both AutomationHook and new main models

Migration renumbered:
- 037_add_automation_hooks → 040_add_automation_hooks
- down_revision: 039_add_classification_rules (was 036_add_document_translation_fields)
- Chain: 036 → 037 → 038 → 039 → 040 (automation hooks)

For all non-automation files with conflicts, main's version was taken since
our branch did not modify those files (conflicts were from a stale prior merge).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 23:54:04 +00:00
parent d1ebac74a1
commit e518bce922
153 changed files with 16306 additions and 922 deletions
+1
View File
@@ -12,6 +12,7 @@ export default function AuthLayout() {
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="qr-scanner" />
</Stack>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* QR scanner route – camera-based QR code scanning for mobile login.
*/
export { default } from "../../src/screens/QRScannerScreen";
+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",
},
});
+55 -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.
@@ -118,8 +158,11 @@ function AuthGuard() {
return (
<Stack screenOptions={{ headerShown: false }}>
<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>
);
}
@@ -131,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
<SafeAreaProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
<LocaleProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider>
);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Root index route – redirects to the auth flow on launch.
*
* expo-router renders this when the "/" route is matched (i.e. on cold start).
* Without this file, a stale default scaffold page ("Hello World") can appear
* if one was left behind by a previous build or Expo CLI scaffolding.
*
* The redirect targets the (auth) group; the AuthGuard in _layout.tsx will
* immediately forward authenticated users to (tabs).
*/
import { Redirect } from "expo-router";
import React from "react";
export default function RootIndex() {
return <Redirect href="/(auth)/" />;
}