diff --git a/mobile/App.tsx b/mobile/App.tsx
index 1396fe1..a3a9cc4 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
+import { SseProvider } from './contexts/SseContext';
+import { SseOcrListener } from './hooks/usePollOcr';
import { LoginScreen } from './app/login';
import { RegisterScreen } from './app/register';
import { HomeScreen } from './app/index';
@@ -103,10 +105,13 @@ function AppNavigator() {
function AppContent() {
return (
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/mobile/api/client.ts b/mobile/api/client.ts
index da78c89..0c23538 100644
--- a/mobile/api/client.ts
+++ b/mobile/api/client.ts
@@ -95,6 +95,68 @@ class ApiClient {
await tokenStorage.deleteUser();
}
+ async subscribeEvents(
+ onEvent: (event: string, data: string) => void,
+ onError: (err: Error) => void,
+ ): Promise<() => void> {
+ const abortController = new AbortController();
+
+ const connect = async () => {
+ try {
+ const headers: Record = {
+ Accept: 'text/event-stream',
+ };
+ if (this.accessToken) {
+ headers['Authorization'] = `Bearer ${this.accessToken}`;
+ }
+
+ const response = await fetch(`${this.baseUrl}${ENDPOINTS.EVENTS}`, {
+ headers,
+ signal: abortController.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`SSE connection failed: ${response.status}`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error('Streaming not supported');
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let currentEvent = '';
+
+ while (!abortController.signal.aborted) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.startsWith('event: ')) {
+ currentEvent = line.slice(7);
+ } else if (line.startsWith('data: ')) {
+ if (currentEvent) {
+ onEvent(currentEvent, line.slice(6));
+ currentEvent = '';
+ }
+ }
+ }
+ }
+ } catch (err) {
+ if (!abortController.signal.aborted) {
+ onError(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+ };
+
+ connect();
+
+ return () => abortController.abort();
+ }
+
async get(endpoint: string): Promise {
return this.request(endpoint);
}
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 44a3323..12e7a04 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -94,7 +94,7 @@ export function HomeScreen() {
const insets = useSafeAreaInsets();
const [page, setPage] = useState(1);
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
- const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
+ const { hasPermission, requestPermission, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered } = useLocalFiles();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
@@ -336,6 +336,11 @@ export function HomeScreen() {
await pickDirectory();
}, [pickDirectory]);
+ const handleAddFolderRecursive = useCallback(async () => {
+ setSettingsModalVisible(false);
+ await pickAndScanRecursive();
+ }, [pickAndScanRecursive]);
+
const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
safDirectory.updateSyncMode(folderId, mode);
refreshFolders();
@@ -438,12 +443,29 @@ export function HomeScreen() {
)}
{files.length === 0 && !searchQuery && (
-
-
-
- {folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'}
-
-
+
+ {folders.length > 0 ? (
+ <>
+
+
+ {folders.length} dossier{folders.length > 1 ? 's' : ''} scanné{folders.length > 1 ? 's' : ''}
+
+
+ Tout scanner
+
+ >
+ ) : (
+ <>
+
+
+ Scanner un dossier de votre appareil
+
+
+ Choisir
+
+ >
+ )}
+
)}
@@ -647,6 +669,7 @@ export function HomeScreen() {
onToggleVisibility={handleToggleFolderVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolder={handleAddFolder}
+ onAddFolderRecursive={handleAddFolderRecursive}
onUpdateSyncMode={handleUpdateSyncMode}
onUpdateSyncCellular={handleUpdateSyncCellular}
globalSyncMode={globalSyncMode}
diff --git a/mobile/components/SettingsModal.tsx b/mobile/components/SettingsModal.tsx
index 0bd616c..ba055c3 100644
--- a/mobile/components/SettingsModal.tsx
+++ b/mobile/components/SettingsModal.tsx
@@ -9,7 +9,7 @@ import {
Alert,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
-import { StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory';
+import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory';
type SettingsView = 'menu' | 'folders' | 'sync';
@@ -20,6 +20,7 @@ interface SettingsModalProps {
onToggleVisibility: (folderId: string) => void;
onRemoveFolder: (folderId: string) => void;
onAddFolder: () => void;
+ onAddFolderRecursive?: () => void;
onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
globalSyncMode: SyncGlobalMode;
@@ -47,6 +48,7 @@ export function SettingsModal({
onToggleVisibility,
onRemoveFolder,
onAddFolder,
+ onAddFolderRecursive,
onUpdateSyncMode,
onUpdateSyncCellular,
globalSyncMode,
@@ -108,6 +110,7 @@ export function SettingsModal({
onToggleVisibility={onToggleVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolder={onAddFolder}
+ onAddFolderRecursive={onAddFolderRecursive}
/>
)}
@@ -163,18 +166,31 @@ function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void;
);
}
+function folderIcon(folder: StoredFolder): { name: keyof typeof MaterialIcons.glyphMap; color: string } {
+ switch (folder.source) {
+ case 'media-library':
+ return { name: 'photo-library', color: '#43A047' };
+ case 'recursive':
+ return { name: 'subdirectory-arrow-right', color: '#8E24AA' };
+ default:
+ return { name: 'folder', color: '#F57C00' };
+ }
+}
+
function FoldersView({
folders,
onBack,
onToggleVisibility,
onRemoveFolder,
onAddFolder,
+ onAddFolderRecursive,
}: {
folders: StoredFolder[];
onBack: () => void;
onToggleVisibility: (id: string) => void;
onRemoveFolder: (folder: StoredFolder) => void;
onAddFolder: () => void;
+ onAddFolderRecursive?: () => void;
}) {
return (
@@ -188,36 +204,46 @@ function FoldersView({
{folders.length === 0 ? (
Aucun dossier configuré
) : (
- folders.map((folder) => (
-
-
-
- {folder.name}
-
- onToggleVisibility(folder.id)}
- style={styles.actionBtn}
- >
-
-
- onRemoveFolder(folder)}
- style={styles.actionBtn}
- >
-
-
-
- ))
+ folders.map((folder) => {
+ const icon = folderIcon(folder);
+ return (
+
+
+
+ {folder.name}
+
+ onToggleVisibility(folder.id)}
+ style={styles.actionBtn}
+ >
+
+
+ onRemoveFolder(folder)}
+ style={styles.actionBtn}
+ >
+
+
+
+ );
+ })
)}
Ajouter un dossier
+
+ {onAddFolderRecursive && (
+
+
+ Ajouter (scan récursif)
+
+ )}
);
}
@@ -504,6 +530,16 @@ const styles = StyleSheet.create({
marginTop: 16,
gap: 8,
},
+ scanRootBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#8E24AA',
+ borderRadius: 10,
+ paddingVertical: 12,
+ marginTop: 10,
+ gap: 8,
+ },
addBtnText: {
fontSize: 15,
color: '#fff',
diff --git a/mobile/components/UploadModal.tsx b/mobile/components/UploadModal.tsx
index 2dd78a7..dfd8be0 100644
--- a/mobile/components/UploadModal.tsx
+++ b/mobile/components/UploadModal.tsx
@@ -1,10 +1,10 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import { useUpload, UploadFile } from '../hooks/useUpload';
-import { usePollOcr } from '../hooks/usePollOcr';
+import { usePollOcr, SseOcrListener } from '../hooks/usePollOcr';
import { UploadProgress } from './UploadProgress';
import { UploadError } from '../types';
import { apiClient } from '../api/client';
diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts
index 18d53c4..b4ecabf 100644
--- a/mobile/constants/api.ts
+++ b/mobile/constants/api.ts
@@ -9,6 +9,7 @@ export const ENDPOINTS = {
VARIANT: '/variants',
DEDUP_CHECK: '/resources/dedup-check',
OCR_JOBS: '/ocr/jobs',
+ EVENTS: '/events',
HEALTH: '/health',
AUTH_LOGIN: '/auth/login',
AUTH_REGISTER: '/auth/register',
diff --git a/mobile/contexts/SseContext.tsx b/mobile/contexts/SseContext.tsx
new file mode 100644
index 0000000..e0a9196
--- /dev/null
+++ b/mobile/contexts/SseContext.tsx
@@ -0,0 +1,84 @@
+import React, { createContext, useContext, useEffect, useRef, useCallback } from 'react';
+import { apiClient } from '../api/client';
+import { useAuth } from './AuthContext';
+
+type OcrDoneCallback = (resourceId: string) => void;
+
+interface SseContextValue {
+ onOcrDone: (cb: OcrDoneCallback) => () => void;
+}
+
+const SseContext = createContext({
+ onOcrDone: () => () => {},
+});
+
+export function SseProvider({ children }: { children: React.ReactNode }) {
+ const { user } = useAuth();
+ const listenersRef = useRef>(new Set());
+ const cancelRef = useRef<() => void>(() => {});
+ const reconnectTimeoutRef = useRef | undefined>(undefined);
+
+ const connect = useCallback(() => {
+ if (!user) return;
+
+ let cancelled = false;
+
+ const start = async () => {
+ try {
+ const cancel = await apiClient.subscribeEvents(
+ (event, data) => {
+ if (event === 'ocr_done') {
+ listenersRef.current.forEach((cb) => cb(data));
+ }
+ },
+ () => {
+ reconnectTimeoutRef.current = setTimeout(connect, 3000);
+ },
+ );
+ if (cancelled) {
+ cancel();
+ } else {
+ cancelRef.current = cancel;
+ }
+ } catch {
+ reconnectTimeoutRef.current = setTimeout(connect, 3000);
+ }
+ };
+
+ start();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [user]);
+
+ useEffect(() => {
+ const cleanup = connect();
+ return () => {
+ cleanup?.();
+ cancelRef.current();
+ clearTimeout(reconnectTimeoutRef.current);
+ };
+ }, [connect]);
+
+ const onOcrDone = useCallback((cb: OcrDoneCallback) => {
+ listenersRef.current.add(cb);
+ return () => {
+ listenersRef.current.delete(cb);
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useOcrDone(onDone?: OcrDoneCallback) {
+ const ctx = useContext(SseContext);
+ useEffect(() => {
+ if (onDone) return ctx.onOcrDone(onDone);
+ }, [onDone, ctx]);
+ return { onOcrDone: ctx.onOcrDone };
+}
diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts
index 578f0f7..1cb82f1 100644
--- a/mobile/hooks/useDeviceFiles.ts
+++ b/mobile/hooks/useDeviceFiles.ts
@@ -54,6 +54,18 @@ function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
};
}
+const SKIP_SUBDIRS = new Set([
+ 'Android', 'android', 'data', 'obb', 'cache',
+ '.thumbnails', '.Trash', 'lost+found',
+ 'LOST.DIR', 'System Volume Information',
+ 'com.android', '.cache', 'tmp', '.tmp',
+]);
+
+function shouldSkipDir(name: string): boolean {
+ if (name.startsWith('.')) return true;
+ return SKIP_SUBDIRS.has(name);
+}
+
async function scanSafFolder(folder: StoredFolder): Promise {
try {
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
@@ -78,11 +90,62 @@ async function scanSafFolder(folder: StoredFolder): Promise {
}
}
+async function scanMediaAlbum(folder: StoredFolder): Promise {
+ try {
+ if (!folder.albumId) return [];
+ const result = await MediaLibrary.getAssetsAsync({
+ album: folder.albumId,
+ first: 500,
+ sortBy: 'creationTime',
+ });
+ return result.assets.map((asset) => ({
+ id: `media_${folder.id}_${asset.id}`,
+ uri: asset.uri,
+ name: asset.filename,
+ mimeType: asset.mediaType === 'video' ? 'video/mp4' : 'image/jpeg',
+ size: 0,
+ createdAt: asset.creationTime
+ ? new Date(asset.creationTime).toISOString()
+ : new Date().toISOString(),
+ folderId: folder.id,
+ }));
+ } catch (err) {
+ return [];
+ }
+}
+
+async function scanSubdirectories(
+ baseUri: string,
+ depth: number = 0,
+ maxDepth: number = 3,
+ discovered: Array<{ uri: string; name: string; parentUri: string }> = []
+): Promise> {
+ if (depth >= maxDepth || discovered.length >= 300) return discovered;
+
+ try {
+ const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(baseUri);
+ for (const entryUri of entries) {
+ const parts = entryUri.split('/');
+ const name = decodeURIComponent(parts[parts.length - 1]);
+ if (shouldSkipDir(name)) continue;
+
+ try {
+ await FileSystem.StorageAccessFramework.readDirectoryAsync(entryUri);
+ discovered.push({ uri: entryUri, name, parentUri: baseUri });
+ await scanSubdirectories(entryUri, depth + 1, maxDepth, discovered);
+ } catch {}
+ }
+ } catch {}
+
+ return discovered;
+}
+
export function useDeviceFiles() {
const [files, setFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [hasPermission, setHasPermission] = useState(false);
const [folders, setFolders] = useState(() => safDirectory.getAll());
+ const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
const { newFiles, clearNewFiles } = useFileWatcher();
const requestPermission = useCallback(async () => {
@@ -125,11 +188,35 @@ export function useDeviceFiles() {
}
}, []);
+ const discoverMediaAlbums = useCallback(async (): Promise => {
+ try {
+ const albums = await MediaLibrary.getAlbumsAsync();
+ let added = 0;
+ for (const album of albums) {
+ if (album.title && album.assetCount > 0) {
+ safDirectory.addMediaFolder(album.id, album.title, '');
+ added++;
+ }
+ }
+ if (added > 0) {
+ setFolders(safDirectory.getAll());
+ }
+ return added;
+ } catch (err) {
+ return 0;
+ }
+ }, []);
+
const scanVisibleFolders = useCallback(async () => {
const visibleFolders = safDirectory.getVisibleFolders();
if (visibleFolders.length === 0) return;
- const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
+ const results = await Promise.all(
+ visibleFolders.map((folder) => {
+ if (folder.source === 'media-library') return scanMediaAlbum(folder);
+ return scanSafFolder(folder);
+ })
+ );
const safFiles = results.flat();
setFiles((prev) => {
@@ -174,7 +261,7 @@ export function useDeviceFiles() {
safDirectory.addFolder(dirUri, dirName);
setFolders(safDirectory.getAll());
- const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false });
+ const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' });
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
@@ -189,6 +276,54 @@ export function useDeviceFiles() {
}
}, []);
+ const pickAndScanRecursive = useCallback(async (): Promise<{ addedFolders: number }> => {
+ try {
+ const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
+ if (!result.granted) return { addedFolders: 0 };
+
+ const dirUri = result.directoryUri;
+ const parts = dirUri.split('/');
+ const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
+
+ safDirectory.addFolder(dirUri, dirName);
+
+ const subdirs = await scanSubdirectories(dirUri);
+ if (subdirs.length > 0) {
+ safDirectory.addBatchFolders(
+ subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri }))
+ );
+ }
+
+ setFolders(safDirectory.getAll());
+
+ if (!discovered) {
+ safDirectory.setDiscovered();
+ setDiscovered(true);
+ }
+
+ await scanVisibleFolders();
+
+ return { addedFolders: 1 + subdirs.length };
+ } catch (err) {
+ return { addedFolders: 0 };
+ }
+ }, [discovered, scanVisibleFolders]);
+
+ const runInitialDiscovery = useCallback(async () => {
+ if (safDirectory.getDiscovered()) return;
+
+ const granted = hasPermission || await requestPermission();
+ if (granted) {
+ const mediaCount = await discoverMediaAlbums();
+ if (mediaCount > 0) {
+ await scanVisibleFolders();
+ }
+ }
+
+ safDirectory.setDiscovered();
+ setDiscovered(true);
+ }, [hasPermission, requestPermission, discoverMediaAlbums, scanVisibleFolders]);
+
const refreshFolders = useCallback(() => {
setFolders(safDirectory.getAll());
}, []);
@@ -206,10 +341,8 @@ export function useDeviceFiles() {
useEffect(() => {
if (newFiles.length === 0) return;
- // Persist to MMKV
downloadRegistry.addBatch(newFiles);
- // Add to in-memory state
const newDeviceFiles = newFiles.map(eventToDeviceFile);
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
@@ -232,8 +365,15 @@ export function useDeviceFiles() {
});
scanVisibleFolders();
loadRegistryFiles();
+ runInitialDiscovery();
}, []);
- return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders };
+ return {
+ files, isLoading, hasPermission,
+ requestPermission, rescan,
+ pickDirectory, pickAndScanRecursive,
+ folders, refreshFolders,
+ discovered, discoverMediaAlbums,
+ };
}
diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts
index 00ca794..1954d28 100644
--- a/mobile/hooks/useLocalFiles.ts
+++ b/mobile/hooks/useLocalFiles.ts
@@ -4,7 +4,7 @@ import { fileStore } from '../services/fileStore';
import { UnifiedFileItem } from '../types';
export function useLocalFiles() {
- const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
+ const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles();
const lastDeviceCount = useRef(0);
useEffect(() => {
@@ -76,7 +76,10 @@ export function useLocalFiles() {
requestPermission,
rescan,
pickDirectory,
+ pickAndScanRecursive,
folders,
refreshFolders,
+ discovered,
+ discoverMediaAlbums,
};
}
diff --git a/mobile/hooks/usePollOcr.ts b/mobile/hooks/usePollOcr.ts
index 68614cf..f171a31 100644
--- a/mobile/hooks/usePollOcr.ts
+++ b/mobile/hooks/usePollOcr.ts
@@ -1,41 +1,75 @@
-import { useCallback, useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { fileStore } from '../services/fileStore';
-
-const MAX_POLL_MS = 120_000;
-const POLL_INTERVAL = 3_000;
+import { useOcrDone } from '../contexts/SseContext';
export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' };
+type OcrState = Record void; timeout: ReturnType }>;
+
+let globalOcrState: OcrState = {};
+let globalSetState: React.Dispatch> | null = null;
+
+function handleOcrDone(resourceId: string) {
+ const entry = globalOcrState[resourceId];
+ if (!entry) return;
+ clearTimeout(entry.timeout);
+
+ apiClient
+ .get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
+ .then((detail) => {
+ const ocrText = detail.data?.ocrText;
+ if (ocrText) {
+ fileStore.updatePartial(resourceId, { ocrText });
+ }
+ entry.resolve({ resourceId, status: 'completed' });
+ })
+ .catch(() => {
+ entry.resolve({ resourceId, status: 'failed' });
+ })
+ .finally(() => {
+ delete globalOcrState[resourceId];
+ globalSetState?.(Date.now());
+ });
+}
+
+export function SseOcrListener() {
+ const { onOcrDone } = useOcrDone();
+
+ useEffect(() => {
+ const unsub = onOcrDone(handleOcrDone);
+ return unsub;
+ }, [onOcrDone]);
+
+ return null;
+}
+
export function usePollOcr() {
- const running = useRef>(new Set());
+ const [, forceUpdate] = useState(0);
+
+ useEffect(() => {
+ globalSetState = forceUpdate;
+ return () => {
+ globalSetState = null;
+ };
+ }, []);
const pollOcr = useCallback(async (resourceId: string): Promise => {
- if (running.current.has(resourceId)) return { resourceId, status: 'failed' };
- running.current.add(resourceId);
-
- const start = Date.now();
- try {
- while (Date.now() - start < MAX_POLL_MS) {
- try {
- const detail = await apiClient.get<{ data: { ocrText?: string } }>(
- `${ENDPOINTS.RESOURCES}/${resourceId}`,
- );
- const ocrText = detail.data?.ocrText;
- if (ocrText && ocrText.length > 0) {
- fileStore.updatePartial(resourceId, { ocrText });
- return { resourceId, status: 'completed' };
- }
- } catch {
- return { resourceId, status: 'failed' };
- }
- await new Promise((r) => setTimeout(r, POLL_INTERVAL));
- }
- return { resourceId, status: 'timeout' };
- } finally {
- running.current.delete(resourceId);
+ const existing = globalOcrState[resourceId];
+ if (existing) {
+ return new Promise((resolve) => existing.resolve = resolve);
}
+
+ return new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ delete globalOcrState[resourceId];
+ resolve({ resourceId, status: 'timeout' });
+ }, 120_000);
+
+ globalOcrState[resourceId] = { resolve, timeout };
+ globalSetState?.(Date.now());
+ });
}, []);
return { pollOcr };
diff --git a/mobile/hooks/useUpload.ts b/mobile/hooks/useUpload.ts
index 4d451fd..d73cf23 100644
--- a/mobile/hooks/useUpload.ts
+++ b/mobile/hooks/useUpload.ts
@@ -1,8 +1,8 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
-import { ApiError, OcrJob, UploadError } from '../types';
+import { ApiError, UploadError } from '../types';
export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string };
@@ -76,15 +76,4 @@ export function useUpload() {
});
}
-export function useOcrJobStatus(jobId: string) {
- return useQuery({
- queryKey: ['ocr', jobId],
- queryFn: () => apiClient.get(`${ENDPOINTS.OCR_JOBS}/${jobId}`),
- enabled: !!jobId,
- refetchInterval: (query: any) => {
- const status = query.state.data?.status;
- if (status === 'completed' || status === 'failed') return false;
- return 1000;
- },
- });
-}
+
diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts
index 87fcb0c..4283d49 100644
--- a/mobile/services/safDirectory.ts
+++ b/mobile/services/safDirectory.ts
@@ -2,6 +2,7 @@ import { createMMKV } from 'react-native-mmkv';
export type SyncMode = 'none' | 'manual' | 'auto';
export type SyncGlobalMode = 'off' | 'auto' | 'manual';
+export type FolderSource = 'saf' | 'media-library' | 'recursive';
export type StoredFolder = {
id: string;
@@ -10,6 +11,9 @@ export type StoredFolder = {
visible: boolean;
syncMode: SyncMode;
syncCellular: boolean;
+ source: FolderSource;
+ albumId?: string;
+ parentUri?: string;
};
const storage = createMMKV({ id: 'vaultdrop-saf' });
@@ -17,6 +21,7 @@ const storage = createMMKV({ id: 'vaultdrop-saf' });
const FOLDERS_KEY = 'saf_folders';
const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode';
const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular';
+const DISCOVERED_KEY = 'folders_discovered';
function generateId(): string {
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -36,6 +41,10 @@ function getAllRaw(): StoredFolder[] {
(f as any).syncCellular = false;
migrated = true;
}
+ if (f.source === undefined) {
+ (f as any).source = 'saf';
+ migrated = true;
+ }
}
if (migrated) saveAll(parsed);
return parsed;
@@ -59,12 +68,67 @@ export const safDirectory = {
if (folders.some((f) => f.uri === uri)) {
return folders.find((f) => f.uri === uri)!;
}
- const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false };
+ const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' };
folders.push(folder);
saveAll(folders);
return folder;
},
+ addMediaFolder(albumId: string, name: string, uri: string): StoredFolder {
+ const folders = getAllRaw();
+ const key = `media://${albumId}`;
+ if (folders.some((f) => f.uri === key)) {
+ return folders.find((f) => f.uri === key)!;
+ }
+ const folder: StoredFolder = {
+ id: generateId(),
+ uri: key,
+ name,
+ visible: true,
+ syncMode: 'none',
+ syncCellular: false,
+ source: 'media-library',
+ albumId,
+ };
+ folders.push(folder);
+ saveAll(folders);
+ return folder;
+ },
+
+ addBatchFolders(folders: Array<{ uri: string; name: string; source?: FolderSource; parentUri?: string }>): StoredFolder[] {
+ const current = getAllRaw();
+ const added: StoredFolder[] = [];
+ for (const f of folders) {
+ if (current.some((existing) => existing.uri === f.uri)) continue;
+ const folder: StoredFolder = {
+ id: generateId(),
+ uri: f.uri,
+ name: f.name,
+ visible: true,
+ syncMode: 'none',
+ syncCellular: false,
+ source: f.source ?? 'recursive',
+ parentUri: f.parentUri,
+ };
+ current.push(folder);
+ added.push(folder);
+ }
+ saveAll(current);
+ return added;
+ },
+
+ getDiscovered(): boolean {
+ return storage.getString(DISCOVERED_KEY) === 'true';
+ },
+
+ setDiscovered() {
+ storage.set(DISCOVERED_KEY, 'true');
+ },
+
+ resetDiscovered() {
+ storage.remove(DISCOVERED_KEY);
+ },
+
removeFolder(id: string) {
const folders = getAllRaw().filter((f) => f.id !== id);
saveAll(folders);
diff --git a/mobile/types/index.ts b/mobile/types/index.ts
index 9bde065..f18fd49 100644
--- a/mobile/types/index.ts
+++ b/mobile/types/index.ts
@@ -124,6 +124,11 @@ export interface RefreshResponse {
refresh_token: string;
}
+export type SseEvent = {
+ event: string;
+ data: string;
+};
+
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
export interface Device {