background upload

This commit is contained in:
m
2026-07-29 19:33:20 +02:00
parent 00084758bf
commit fe4fb6020e
6 changed files with 210 additions and 1 deletions
+7
View File
@@ -10,6 +10,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
import { DeviceProvider, useDevice } from './contexts/DeviceContext'; import { DeviceProvider, useDevice } from './contexts/DeviceContext';
import { SseProvider } from './contexts/SseContext'; import { SseProvider } from './contexts/SseContext';
import { SseOcrListener } from './hooks/usePollOcr'; import { SseOcrListener } from './hooks/usePollOcr';
import { registerBackgroundUpload } from './services/backgroundUpload';
import { LoginScreen } from './app/login'; import { LoginScreen } from './app/login';
import { RegisterScreen } from './app/register'; import { RegisterScreen } from './app/register';
import { HomeScreen } from './app/index'; import { HomeScreen } from './app/index';
@@ -44,6 +45,12 @@ function AppNavigator() {
const { user, isLoading: authLoading } = useAuth(); const { user, isLoading: authLoading } = useAuth();
const { isLoading: deviceLoading } = useDevice(); const { isLoading: deviceLoading } = useDevice();
useEffect(() => {
if (user) {
registerBackgroundUpload();
}
}, [user]);
if (authLoading || (user && deviceLoading)) { if (authLoading || (user && deviceLoading)) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
+2 -1
View File
@@ -46,7 +46,8 @@
} }
], ],
"expo-image", "expo-image",
"expo-sqlite" "expo-sqlite",
"expo-background-task"
] ]
} }
} }
+33
View File
@@ -17,6 +17,7 @@
"@tanstack/react-query-persist-client": "^5.101.4", "@tanstack/react-query-persist-client": "^5.101.4",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"expo": "~57.0.4", "expo": "~57.0.4",
"expo-background-task": "~57.0.6",
"expo-document-picker": "~57.0.0", "expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0", "expo-file-system": "~57.0.0",
"expo-image": "~57.0.1", "expo-image": "~57.0.1",
@@ -26,6 +27,7 @@
"expo-secure-store": "~57.0.0", "expo-secure-store": "~57.0.0",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"expo-task-manager": "~57.0.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
@@ -4173,6 +4175,18 @@
} }
} }
}, },
"node_modules/expo-background-task": {
"version": "57.0.6",
"resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-57.0.6.tgz",
"integrity": "sha512-nqKqFBvPAyQfIBXhm7OvwY99zP52EeUkoaJJum6JzFGugBSJu3Lgf7zM+0sz9klqlPtHkO7ksKyGdwH2jvz+rA==",
"license": "MIT",
"dependencies": {
"expo-task-manager": "~57.0.6"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": { "node_modules/expo-document-picker": {
"version": "57.0.0", "version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.0.tgz", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.0.tgz",
@@ -4355,6 +4369,19 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-task-manager": {
"version": "57.0.6",
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-57.0.6.tgz",
"integrity": "sha512-nD6tvC3HNzjpOJir3Q8QDNOMtKrY2f1ik+fmKcOl7i/o822AEgGr7HpmDF/kpCQYGchdqBrodmkKcYwb9gxliQ==",
"license": "MIT",
"dependencies": {
"unimodules-app-loader": "~57.0.1"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": { "node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.6", "version": "57.0.6",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.6.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.6.tgz",
@@ -8120,6 +8147,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unimodules-app-loader": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-57.0.1.tgz",
"integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+2
View File
@@ -12,6 +12,7 @@
"@tanstack/react-query-persist-client": "^5.101.4", "@tanstack/react-query-persist-client": "^5.101.4",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"expo": "~57.0.4", "expo": "~57.0.4",
"expo-background-task": "~57.0.6",
"expo-document-picker": "~57.0.0", "expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0", "expo-file-system": "~57.0.0",
"expo-image": "~57.0.1", "expo-image": "~57.0.1",
@@ -21,6 +22,7 @@
"expo-secure-store": "~57.0.0", "expo-secure-store": "~57.0.0",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"expo-task-manager": "~57.0.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
+62
View File
@@ -0,0 +1,62 @@
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
const BACKGROUND_UPLOAD_TASK = 'BACKGROUND_UPLOAD';
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try {
const { uploadQueue } = await import('./uploadQueue');
const { apiClient } = await import('../api/client');
const { tokenStorage } = await import('../api/secureStorage');
const pending = uploadQueue.getPendingCount();
if (pending === 0) {
return BackgroundTask.BackgroundTaskResult.Success;
}
const token = await tokenStorage.getAccessToken();
if (!token) {
return BackgroundTask.BackgroundTaskResult.Success;
}
apiClient.setAccessToken(token);
uploadQueue.retryAll();
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (uploadQueue.getPendingCount() === 0) {
clearInterval(check);
resolve();
}
}, 1000);
setTimeout(() => {
clearInterval(check);
resolve();
}, 25000);
});
return BackgroundTask.BackgroundTaskResult.Success;
} catch {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
let isRegistered = false;
export async function registerBackgroundUpload() {
if (isRegistered) return;
isRegistered = true;
const status = await BackgroundTask.getStatusAsync();
if (status === BackgroundTask.BackgroundTaskStatus.Restricted) {
console.warn('[BackgroundUpload] Permission refusée');
return;
}
await BackgroundTask.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
minimumInterval: 15,
});
console.log('[BackgroundUpload] Enregistré (intervalle: 15min)');
}
+104
View File
@@ -1,4 +1,5 @@
import { File, UploadType } from 'expo-file-system'; import { File, UploadType } from 'expo-file-system';
import { createMMKV } from 'react-native-mmkv';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, UploadError } from '../types'; import { ApiError, UploadError } from '../types';
@@ -21,6 +22,69 @@ export type UploadTask = {
type Listener = () => void; type Listener = () => void;
const storage = createMMKV({ id: 'vaultdrop-upload-queue' });
const STORAGE_KEY = 'tasks';
function serialize(task: UploadTask): unknown {
return {
id: task.id,
file: task.file,
status: task.status,
progress: task.progress,
result: task.result ?? null,
error: task.error ?? null,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
function deserialize(data: unknown): UploadTask | null {
const d = data as Record<string, unknown>;
if (!d || !d.id || !d.file) return null;
const file = d.file as Record<string, string>;
if (!file.uri || !file.type || !file.name) return null;
return {
id: d.id as string,
file: { uri: file.uri, type: file.type, name: file.name },
status: d.status as UploadTaskStatus,
progress: d.progress as number,
result: d.result ? (d.result as UploadResult) : undefined,
error: d.error ? (d.error as string) : undefined,
createdAt: d.createdAt as number,
updatedAt: d.updatedAt as number,
};
}
function saveTasks(tasks: UploadTask[]) {
const persistable = tasks
.filter((t) => t.status !== 'done')
.map(serialize);
storage.set(STORAGE_KEY, JSON.stringify(persistable));
}
function loadTasks(): UploadTask[] {
try {
const raw = storage.getString(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown[] = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const tasks: UploadTask[] = [];
let maxId = 0;
for (const item of parsed) {
const t = deserialize(item);
if (t) {
tasks.push(t);
const num = parseInt(t.id.replace('upload_', ''), 10);
if (num > maxId) maxId = num;
}
}
nextId = maxId;
return tasks;
} catch {
return [];
}
}
let nextId = 0; let nextId = 0;
function genId() { function genId() {
nextId++; nextId++;
@@ -34,6 +98,30 @@ class UploadQueue {
private active = 0; private active = 0;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null; private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.tasks = loadTasks();
const pendingExist = this.tasks.some(
(t) => t.status === 'pending' || t.status === 'uploading',
);
if (pendingExist) {
setTimeout(() => {
this.restartUploading();
this.processNext();
}, 0);
}
}
private restartUploading() {
for (const task of this.tasks) {
if (task.status === 'uploading') {
task.status = 'pending';
task.progress = 0;
task.updatedAt = Date.now();
}
}
this.persist();
}
getTasks(): UploadTask[] { getTasks(): UploadTask[] {
return this.tasks; return this.tasks;
} }
@@ -47,6 +135,10 @@ class UploadQueue {
this.listeners.forEach((l) => l()); this.listeners.forEach((l) => l());
} }
private persist() {
saveTasks(this.tasks);
}
enqueue(files: UploadFile[]) { enqueue(files: UploadFile[]) {
const now = Date.now(); const now = Date.now();
for (const file of files) { for (const file of files) {
@@ -59,6 +151,7 @@ class UploadQueue {
updatedAt: now, updatedAt: now,
}); });
} }
this.persist();
this.notify(); this.notify();
this.processNext(); this.processNext();
} }
@@ -69,6 +162,7 @@ class UploadQueue {
task.status = 'error'; task.status = 'error';
task.error = 'Annulé'; task.error = 'Annulé';
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.persist();
this.notify(); this.notify();
} }
@@ -80,6 +174,7 @@ class UploadQueue {
task.error = undefined; task.error = undefined;
task.result = undefined; task.result = undefined;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.persist();
this.notify(); this.notify();
this.processNext(); this.processNext();
} }
@@ -94,10 +189,15 @@ class UploadQueue {
task.updatedAt = Date.now(); task.updatedAt = Date.now();
} }
} }
this.persist();
this.notify(); this.notify();
this.processNext(); this.processNext();
} }
getPendingCount(): number {
return this.tasks.filter((t) => t.status === 'pending' || t.status === 'uploading').length;
}
private processNext() { private processNext() {
while (this.active < this.concurrency) { while (this.active < this.concurrency) {
const next = this.tasks.find((t) => t.status === 'pending'); const next = this.tasks.find((t) => t.status === 'pending');
@@ -105,6 +205,7 @@ class UploadQueue {
this.active++; this.active++;
next.status = 'uploading'; next.status = 'uploading';
next.updatedAt = Date.now(); next.updatedAt = Date.now();
this.persist();
this.notify(); this.notify();
this.runTask(next); this.runTask(next);
} }
@@ -146,6 +247,7 @@ class UploadQueue {
task.progress = 100; task.progress = 100;
task.result = item as UploadResult; task.result = item as UploadResult;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.persist();
this.notify(); this.notify();
this.scheduleCleanup(); this.scheduleCleanup();
} catch (err) { } catch (err) {
@@ -157,6 +259,7 @@ class UploadQueue {
? err.message ? err.message
: 'Erreur inconnue'; : 'Erreur inconnue';
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.persist();
this.notify(); this.notify();
} finally { } finally {
this.active--; this.active--;
@@ -175,6 +278,7 @@ class UploadQueue {
(t) => t.status !== 'done' || now - t.updatedAt < 5000, (t) => t.status !== 'done' || now - t.updatedAt < 5000,
); );
if (this.tasks.length !== before) { if (this.tasks.length !== before) {
this.persist();
this.notify(); this.notify();
} }
if (this.tasks.some((t) => t.status === 'done')) { if (this.tasks.some((t) => t.status === 'done')) {