background upload
This commit is contained in:
@@ -10,6 +10,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
|
||||
import { SseProvider } from './contexts/SseContext';
|
||||
import { SseOcrListener } from './hooks/usePollOcr';
|
||||
import { registerBackgroundUpload } from './services/backgroundUpload';
|
||||
import { LoginScreen } from './app/login';
|
||||
import { RegisterScreen } from './app/register';
|
||||
import { HomeScreen } from './app/index';
|
||||
@@ -44,6 +45,12 @@ function AppNavigator() {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const { isLoading: deviceLoading } = useDevice();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
registerBackgroundUpload();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (authLoading || (user && deviceLoading)) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@
|
||||
}
|
||||
],
|
||||
"expo-image",
|
||||
"expo-sqlite"
|
||||
"expo-sqlite",
|
||||
"expo-background-task"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+33
@@ -17,6 +17,7 @@
|
||||
"@tanstack/react-query-persist-client": "^5.101.4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"expo": "~57.0.4",
|
||||
"expo-background-task": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image": "~57.0.1",
|
||||
@@ -26,6 +27,7 @@
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"expo-task-manager": "~57.0.6",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.86.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": {
|
||||
"version": "57.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.0.tgz",
|
||||
@@ -4355,6 +4369,19 @@
|
||||
"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": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.6.tgz",
|
||||
@@ -8120,6 +8147,12 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@tanstack/react-query-persist-client": "^5.101.4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"expo": "~57.0.4",
|
||||
"expo-background-task": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image": "~57.0.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"expo-task-manager": "~57.0.6",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { File, UploadType } from 'expo-file-system';
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { apiClient } from '../api/client';
|
||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||
import { ApiError, UploadError } from '../types';
|
||||
@@ -21,6 +22,69 @@ export type UploadTask = {
|
||||
|
||||
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;
|
||||
function genId() {
|
||||
nextId++;
|
||||
@@ -34,6 +98,30 @@ class UploadQueue {
|
||||
private active = 0;
|
||||
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[] {
|
||||
return this.tasks;
|
||||
}
|
||||
@@ -47,6 +135,10 @@ class UploadQueue {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
private persist() {
|
||||
saveTasks(this.tasks);
|
||||
}
|
||||
|
||||
enqueue(files: UploadFile[]) {
|
||||
const now = Date.now();
|
||||
for (const file of files) {
|
||||
@@ -59,6 +151,7 @@ class UploadQueue {
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
@@ -69,6 +162,7 @@ class UploadQueue {
|
||||
task.status = 'error';
|
||||
task.error = 'Annulé';
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
@@ -80,6 +174,7 @@ class UploadQueue {
|
||||
task.error = undefined;
|
||||
task.result = undefined;
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
@@ -94,10 +189,15 @@ class UploadQueue {
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.tasks.filter((t) => t.status === 'pending' || t.status === 'uploading').length;
|
||||
}
|
||||
|
||||
private processNext() {
|
||||
while (this.active < this.concurrency) {
|
||||
const next = this.tasks.find((t) => t.status === 'pending');
|
||||
@@ -105,6 +205,7 @@ class UploadQueue {
|
||||
this.active++;
|
||||
next.status = 'uploading';
|
||||
next.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.runTask(next);
|
||||
}
|
||||
@@ -146,6 +247,7 @@ class UploadQueue {
|
||||
task.progress = 100;
|
||||
task.result = item as UploadResult;
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.scheduleCleanup();
|
||||
} catch (err) {
|
||||
@@ -157,6 +259,7 @@ class UploadQueue {
|
||||
? err.message
|
||||
: 'Erreur inconnue';
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
} finally {
|
||||
this.active--;
|
||||
@@ -175,6 +278,7 @@ class UploadQueue {
|
||||
(t) => t.status !== 'done' || now - t.updatedAt < 5000,
|
||||
);
|
||||
if (this.tasks.length !== before) {
|
||||
this.persist();
|
||||
this.notify();
|
||||
}
|
||||
if (this.tasks.some((t) => t.status === 'done')) {
|
||||
|
||||
Reference in New Issue
Block a user