diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx
index d3e358a..ba6f0f2 100644
--- a/mobile/app/login.tsx
+++ b/mobile/app/login.tsx
@@ -1,6 +1,16 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
-import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import i18n from '../i18n';
@@ -46,49 +56,61 @@ export default function Login() {
};
return (
-
- {i18n.t('login_subtitle')}
-
-
- {error ? {error} : null}
-
+
-
- {submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
-
-
- {
- continueWithoutAccount();
- router.replace('/');
- }}
- >
- {i18n.t('login_skip')}
-
-
+ {i18n.t('login_subtitle')}
+
+
+ {error ? {error} : null}
+
+
+ {submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
+
+
+ {
+ continueWithoutAccount();
+ router.replace('/');
+ }}
+ >
+ {i18n.t('login_skip')}
+
+
+
);
}
const styles = StyleSheet.create({
- container: {
+ flex: {
flex: 1,
+ },
+ container: {
+ flexGrow: 1,
backgroundColor: '#fff',
padding: 24,
justifyContent: 'center',
diff --git a/mobile/context/AuthContext.tsx b/mobile/context/AuthContext.tsx
index b9544dd..2673c11 100644
--- a/mobile/context/AuthContext.tsx
+++ b/mobile/context/AuthContext.tsx
@@ -60,10 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
bootstrap();
- useSyncDevice();
+ // Boucle de sync en arrière-plan (décalée, annulée au unmount).
+ const stopSync = useSyncDevice();
return () => {
active = false;
+ stopSync();
};
}, []);
diff --git a/mobile/features/syncDevice.ts b/mobile/features/syncDevice.ts
index bf9ca0c..662bddc 100644
--- a/mobile/features/syncDevice.ts
+++ b/mobile/features/syncDevice.ts
@@ -1,6 +1,7 @@
-import { listDirectory, listFolders } from '../services/safDirectory';
+import { listDirectory, listFoldersChunked, yieldToMainThread } from '../services/safDirectory';
import type { FileEntry } from '../services/safDirectory.types';
import {
+ checkpointDatabase,
getFiles,
getFolder,
getFolders,
@@ -47,9 +48,9 @@ export async function syncRoot(rootResourceId: string): Promise {
return withTransaction(async () => {
const seen = new Set();
- const folders = listFolders(root.uri as string, { recursive: true, includeRoot: true }).sort(
- (a, b) => uriDepth(a.uri) - uriDepth(b.uri),
- );
+ const folders = (
+ await listFoldersChunked(root.uri as string, { recursive: true, includeRoot: true })
+ ).sort((a, b) => uriDepth(a.uri) - uriDepth(b.uri));
const resourceIdByUri = new Map();
const savedFolders: StoredFolder[] = [];
@@ -66,9 +67,14 @@ export async function syncRoot(rootResourceId: string): Promise {
savedFolders.push(saved);
}
+ let lastYield = Date.now();
let files = 0;
for (const folder of savedFolders) {
if (!folder.uri) continue;
+ if (Date.now() - lastYield >= 16) {
+ lastYield = Date.now();
+ await yieldToMainThread();
+ }
for (const entry of listDirectory(folder.uri)) {
if (entry.isDirectory) continue;
seen.add(entry.uri);
@@ -109,15 +115,18 @@ export async function syncDevice(): Promise {
for (const root of roots) {
results.push(await syncRoot(root.resource_id));
}
+ await checkpointDatabase();
return results;
}
-export async function useSyncDevice(intervalMs = 30_000): Promise {
- const preferences = await getUserPreferences();
- if (preferences.syncMode === 'none') return;
+export function useSyncDevice(intervalMs = 30_000): () => void {
+ let stopped = false;
- while (true) {
+ const tick = async () => {
try {
+ const preferences = await getUserPreferences();
+ if (preferences.syncMode === 'none') return;
+
const results = await syncDevice();
console.info('syncDevice', JSON.stringify(results));
// Puis pousser l'outbox (si un token est disponible) et rafraîchir le
@@ -133,6 +142,24 @@ export async function useSyncDevice(intervalMs = 30_000): Promise {
} catch (error) {
console.warn('syncDevice failed, retrying later', error);
}
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
- }
+ };
+
+ const loop = async () => {
+ while (!stopped) {
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ if (stopped) return;
+ await tick();
+ }
+ };
+
+ // Premier cycle différé : laisser le boot et l'écran actif répondre avant
+ // de lancer un walk SAF (potentiellement long) en arrière-plan.
+ const first = setTimeout(() => {
+ void loop();
+ }, 2000);
+
+ return () => {
+ stopped = true;
+ clearTimeout(first);
+ };
}
\ No newline at end of file
diff --git a/mobile/package.json b/mobile/package.json
index 5710245..bb1a2e6 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -38,8 +38,9 @@
"test:migrations": "tsx --test tests/migrations.test.ts",
"test:api": "tsx --test tests/apiClient.test.ts",
"test:sync": "tsx --test tests/syncOutbox.test.ts",
+ "test:saf": "tsx --test tests/safWalk.test.ts",
"test:e2e": "tsx --test tests/e2e.live.test.ts",
- "test": "npm run test:db && npm run test:api && npm run test:sync"
+ "test": "npm run test:db && npm run test:api && npm run test:sync && npm run test:saf"
},
"private": true
}
diff --git a/mobile/services/db/client.ts b/mobile/services/db/client.ts
index 5bca1b4..da17b59 100644
--- a/mobile/services/db/client.ts
+++ b/mobile/services/db/client.ts
@@ -98,6 +98,15 @@ export async function closeDatabase(): Promise {
opening = null;
}
+export async function checkpointDatabase(): Promise {
+ try {
+ const db = await getDatabase();
+ await db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)');
+ } catch {
+ // Best-effort : ne jamais faire échouer la sync sur un checkpoint.
+ }
+}
+
export async function withTransaction(
work: (db: SQLiteDatabase) => Promise,
): Promise {
diff --git a/mobile/services/db/index.ts b/mobile/services/db/index.ts
index 4925371..f6ba47c 100644
--- a/mobile/services/db/index.ts
+++ b/mobile/services/db/index.ts
@@ -1,4 +1,9 @@
-export { getDatabase, closeDatabase, withTransaction } from './client';
+export {
+ getDatabase,
+ closeDatabase,
+ withTransaction,
+ checkpointDatabase,
+} from './client';
export { migrateDatabase, type Migration, type MigrationDb } from './migrations';
export * from './repositories';
export type { AccessCheck, AccessSource } from './repositories/permissions';
diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts
index 0536477..415c3ad 100644
--- a/mobile/services/safDirectory.ts
+++ b/mobile/services/safDirectory.ts
@@ -6,6 +6,14 @@ import type {
FolderInfo,
PickDirectoryOptions,
} from './safDirectory.types';
+import { __setSafWalkImpl, yieldToMainThread } from './safWalk';
+export { listFoldersChunked, yieldToMainThread, DEFAULT_WALK_BUDGET_MS } from './safWalk';
+
+__setSafWalkImpl({
+ list: listDirectory,
+ info: getFolderInfo,
+ yield: yieldToMainThread,
+});
export async function pickDirectory(initialUri?: string): Promise {
try {
diff --git a/mobile/services/safWalk.ts b/mobile/services/safWalk.ts
new file mode 100644
index 0000000..0adfe57
--- /dev/null
+++ b/mobile/services/safWalk.ts
@@ -0,0 +1,69 @@
+import type {
+ DirectoryEntry,
+ Folder,
+ FolderInfo,
+ PickDirectoryOptions,
+} from './safDirectory.types';
+
+export async function yieldToMainThread(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+export const DEFAULT_WALK_BUDGET_MS = 16;
+
+export type SafWalkImpl = {
+ list: (uri: string) => DirectoryEntry[];
+ info: (uri: string) => FolderInfo;
+ yield: () => Promise;
+};
+
+let walkImpl: SafWalkImpl | null = null;
+
+export function __setSafWalkImpl(impl: SafWalkImpl): void {
+ walkImpl = impl;
+}
+
+export function __setSafWalkForTests(impl: Partial): () => void {
+ const previous = walkImpl;
+ walkImpl = { ...previous, ...impl } as SafWalkImpl;
+ return () => {
+ walkImpl = previous;
+ };
+}
+
+export async function listFoldersChunked(
+ directoryUri: string,
+ options: PickDirectoryOptions = {},
+ budgetMs = DEFAULT_WALK_BUDGET_MS,
+): Promise {
+ const impl = walkImpl;
+ if (!impl) throw new Error('SafWalkImpl not configured');
+
+ const { recursive = false, includeRoot = false } = options;
+ const result: Folder[] = [];
+ if (includeRoot) {
+ result.push({ ...impl.info(directoryUri), isDirectory: true });
+ }
+ if (!recursive) {
+ for (const entry of impl.list(directoryUri)) {
+ if (entry.isDirectory) result.push(entry);
+ }
+ return result;
+ }
+
+ let lastYield = Date.now();
+ const visit = async (uri: string) => {
+ if (Date.now() - lastYield >= budgetMs) {
+ lastYield = Date.now();
+ await impl.yield();
+ }
+ for (const entry of impl.list(uri)) {
+ if (entry.isDirectory) {
+ result.push(entry);
+ await visit(entry.uri);
+ }
+ }
+ };
+ await visit(directoryUri);
+ return result;
+}
\ No newline at end of file
diff --git a/mobile/tests/safWalk.test.ts b/mobile/tests/safWalk.test.ts
new file mode 100644
index 0000000..d39ebf6
--- /dev/null
+++ b/mobile/tests/safWalk.test.ts
@@ -0,0 +1,129 @@
+import { test, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { __setSafWalkForTests, listFoldersChunked } from '../services/safWalk';
+import type { DirectoryEntry, Folder } from '../services/safDirectory.types';
+
+function dir(uri: string): Folder {
+ return { uri, name: uri.split('/').pop() ?? uri, isDirectory: true, exists: true };
+}
+
+function file(uri: string): DirectoryEntry {
+ return {
+ uri,
+ name: uri.split('/').pop() ?? uri,
+ isDirectory: false,
+ extension: '',
+ exists: true,
+ size: 1,
+ type: 'text/plain',
+ lastModified: 0,
+ };
+}
+
+function info(uri: string): { uri: string; name: string; exists: boolean } {
+ return { uri, name: uri.split('/').pop() ?? uri, exists: true };
+}
+
+const tree: Record = {
+ '/root': [dir('/root/a'), dir('/root/b'), dir('/root/c'), file('/root/f1')],
+ '/root/a': [dir('/root/a/x'), file('/root/a/f2')],
+ '/root/a/x': [],
+ '/root/b': [file('/root/b/f3')],
+ '/root/c': [],
+};
+
+const restoreFns: Array<() => void> = [];
+
+afterEach(() => {
+ while (restoreFns.length) restoreFns.pop()?.();
+});
+
+function fakeSaf() {
+ return __setSafWalkForTests({
+ list: (uri) => tree[uri] ?? [],
+ info,
+ });
+}
+
+test('listFoldersChunked : parcours récursif identique à listFolders', async () => {
+ restoreFns.push(fakeSaf());
+ const folders = await listFoldersChunked('/root', { recursive: true, includeRoot: true });
+ assert.deepEqual(
+ folders.map((f) => f.uri),
+ ['/root', '/root/a', '/root/a/x', '/root/b', '/root/c'],
+ );
+});
+
+test('listFoldersChunked : non-récursif ne remonte que les sous-dossiers immédiats', async () => {
+ restoreFns.push(fakeSaf());
+ const folders = await listFoldersChunked('/root');
+ assert.deepEqual(
+ folders.map((f) => f.uri),
+ ['/root/a', '/root/b', '/root/c'],
+ );
+});
+
+test('listFoldersChunked : includeRoot=false ne retourne pas la racine', async () => {
+ restoreFns.push(fakeSaf());
+ const folders = await listFoldersChunked('/root', { recursive: true });
+ assert.deepEqual(
+ folders.map((f) => f.uri),
+ ['/root/a', '/root/a/x', '/root/b', '/root/c'],
+ );
+});
+
+test('listFoldersChunked : cède au event loop (budget 0 → yield par dossier)', async () => {
+ restoreFns.push(fakeSaf());
+ let yields = 0;
+ restoreFns.push(
+ __setSafWalkForTests({
+ yield: async () => {
+ yields++;
+ },
+ }),
+ );
+
+ const folders = await listFoldersChunked('/root', { recursive: true }, 0);
+ assert.deepEqual(
+ folders.map((f) => f.uri),
+ ['/root/a', '/root/a/x', '/root/b', '/root/c'],
+ );
+ assert.ok(yields >= 5, `expected at least one yield per folder, got ${yields}`);
+});
+
+test('listFoldersChunked : budget maximum → aucun yield, résultat inchangé', async () => {
+ restoreFns.push(fakeSaf());
+ let yields = 0;
+ restoreFns.push(
+ __setSafWalkForTests({
+ yield: async () => {
+ yields++;
+ },
+ }),
+ );
+
+ const folders = await listFoldersChunked('/root', { recursive: true }, Number.MAX_SAFE_INTEGER);
+ assert.deepEqual(
+ folders.map((f) => f.uri),
+ ['/root/a', '/root/a/x', '/root/b', '/root/c'],
+ );
+ assert.equal(yields, 0);
+});
+
+test('__setSafWalkForTests : la restauration rend l’implémentation d’origine', async () => {
+ restoreFns.push(fakeSaf());
+ const restore = __setSafWalkForTests({ list: () => [dir('/fake')] });
+ restore();
+
+ let listed = false;
+ restoreFns.push(
+ __setSafWalkForTests({
+ list: (uri) => {
+ listed = true;
+ return tree[uri] ?? [];
+ },
+ }),
+ );
+ await listFoldersChunked('/root', { recursive: true });
+ assert.equal(listed, true);
+});
\ No newline at end of file