From 76ed3aa66bcffbbb54ceea9c8b724368a1dd6929 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 23:08:17 +0200 Subject: [PATCH] add saf directory display fix --- mobile/app/login.tsx | 98 ++++++++++++++---------- mobile/context/AuthContext.tsx | 4 +- mobile/features/syncDevice.ts | 47 +++++++++--- mobile/package.json | 3 +- mobile/services/db/client.ts | 9 +++ mobile/services/db/index.ts | 7 +- mobile/services/safDirectory.ts | 8 ++ mobile/services/safWalk.ts | 69 +++++++++++++++++ mobile/tests/safWalk.test.ts | 129 ++++++++++++++++++++++++++++++++ 9 files changed, 323 insertions(+), 51 deletions(-) create mode 100644 mobile/services/safWalk.ts create mode 100644 mobile/tests/safWalk.test.ts 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