background upload
This commit is contained in:
@@ -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
@@ -46,7 +46,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-image",
|
"expo-image",
|
||||||
"expo-sqlite"
|
"expo-sqlite",
|
||||||
|
"expo-background-task"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+33
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user