process gate document

This commit is contained in:
m
2026-09-14 09:46:08 +02:00
parent e0fd9ce99a
commit a973d789cd
2 changed files with 36 additions and 39 deletions
+4 -4
View File
@@ -52,11 +52,11 @@ Tests mobiles : JVM unit tests dans `app/src/test` (JUnit + Robolectric + Room i
- Entry point: `app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt` (Hilt + `HiltWorkerFactory`) + `MainActivity.kt` (Hilt) ; navigation Compose dans `ui/navigation/` (`NavGraph.kt`, `VaultDropApp.kt`) - Entry point: `app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt` (Hilt + `HiltWorkerFactory`) + `MainActivity.kt` (Hilt) ; navigation Compose dans `ui/navigation/` (`NavGraph.kt`, `VaultDropApp.kt`)
- Data layer — `data/`: - Data layer — `data/`:
- `data/local/` — Room SQLite (DB `dot.db`, **version 7**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`, `pending_operations`) : entités Folder/File/UserPreference/PendingOperation, DAO, tri (`FileOrdering`) ; flag `processed` sur `files` (mode review « traiter », local au device) ; outbox transactionnel `pending_operations` (v7) - `data/local/` — Room SQLite (DB `dot.db`, **version 7**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`, `pending_operations`) : entités Folder/File/UserPreference/PendingOperation, DAO, tri (`FileOrdering`) ; flag `processed` sur `files` (mode review « traiter », local au device, **gate de sync** : un fichier pas encore traité n'est jamais poussé) ; outbox transactionnel `pending_operations` (v7)
- `data/remote/` — Retrofit/Moshi : `ApiService.kt` + `dto/Dtos.kt` = **contrat HTTP** (`{ data, meta }`, erreurs `{ error: { code, message } }`) ; `ApiClient.kt` normalise les réponses ; interceptors OkHttp (`AuthInterceptor`, `ServerUrlInterceptor`) - `data/remote/` — Retrofit/Moshi : `ApiService.kt` + `dto/Dtos.kt` = **contrat HTTP** (`{ data, meta }`, erreurs `{ error: { code, message } }`) ; `ApiClient.kt` normalise les réponses ; interceptors OkHttp (`AuthInterceptor`, `ServerUrlInterceptor`)
- `data/repository/``FolderRepository`, `FileRepository`, `AuthRepository`, `OutboxRepository` (enqueue `create_resource`/`move_resource`/`delete_resource`, écrivains dans la même transaction Room que la mutation parente) - `data/repository/``FolderRepository`, `FileRepository`, `AuthRepository`, `OutboxRepository` (enqueue `create_resource`/`move_resource`/`delete_resource`, écrivains dans la même transaction Room que la mutation parente ; pour un fichier SAF, la mutation parente du `create_resource` est le **garder** de la review (`markProcessed`), et `hasCreateOperation` en garde l'unicité)
- `features/` — logique descendue côté client : - `features/` — logique descendue côté client :
- `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex` ; chaque nouvelle ressource découverte est journalisée dans l'outbox - `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex` ; les nouveaux **dossiers** sont journalisés dans l'outbox à la découverte, les **fichiers** uniquement au « garder » de la review (gate `processed`)
- `saf/``SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule ; le déplacement est poussé en `move_resource`), `SafFileDeleter` (suppression SAF + `delete_resource` atomique) - `saf/``SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule ; le déplacement est poussé en `move_resource`), `SafFileDeleter` (suppression SAF + `delete_resource` atomique)
- `sync/OutboxSyncWorker.kt` — worker WorkManager (`enqueueUniqueWork` KEEP, single-flight, `NetworkType.CONNECTED`, backoff 30s) qui draine `pending_operations` vers `POST /sync/ops` (batch 20, dead-letter immédiat `failed` sur erreur 4xx non-idempotente — `attempts` diagnostic, purge synced > 7 jours) ; **schedulé** au login/restauration de session (`AuthViewModel`) et après chaque `syncAll()` (`SyncViewModel`) - `sync/OutboxSyncWorker.kt` — worker WorkManager (`enqueueUniqueWork` KEEP, single-flight, `NetworkType.CONNECTED`, backoff 30s) qui draine `pending_operations` vers `POST /sync/ops` (batch 20, dead-letter immédiat `failed` sur erreur 4xx non-idempotente — `attempts` diagnostic, purge synced > 7 jours) ; **schedulé** au login/restauration de session (`AuthViewModel`) et après chaque `syncAll()` (`SyncViewModel`)
- `sync/SyncViewModel.kt` — état du sync exposé à l'UI (`SyncStatus` : marche en cours, ops en attente, dernières opérations) via le badge header `ui/components/SyncStatusBadge.kt` + `SyncOperationsDialog.kt` (« liste des sync ») - `sync/SyncViewModel.kt` — état du sync exposé à l'UI (`SyncStatus` : marche en cours, ops en attente, dernières opérations) via le badge header `ui/components/SyncStatusBadge.kt` + `SyncOperationsDialog.kt` (« liste des sync »)
@@ -74,7 +74,7 @@ Tests mobiles : JVM unit tests dans `app/src/test` (JUnit + Robolectric + Room i
- `owner_id` is **nullable** in the Room schema; it is set at runtime from `DeviceIdentity.getOrCreate()` during the SAF walk. - `owner_id` is **nullable** in the Room schema; it is set at runtime from `DeviceIdentity.getOrCreate()` during the SAF walk.
- Folder/file `sync_status` is a **placement** state: `local` | `cloud` | `local-cloud`. It is not a push progress marker. - Folder/file `sync_status` is a **placement** state: `local` | `cloud` | `local-cloud`. It is not a push progress marker.
- Client storage: `user_preferences` (clé/valeur) pour l'identité device, le compte actif, l'URL serveur, le thème, le mode local. - Client storage: `user_preferences` (clé/valeur) pour l'identité device, le compte actif, l'URL serveur, le thème, le mode local.
- Outbox atomique : `create_resource`/`move_resource`/`delete_resource` sont écrits dans la **même transaction Room** que la mutation parente ; l'écrivain (repository) et le worker partagent ce contrat. En mode local (pas de token), le worker est no-op. - Outbox atomique : `create_resource`/`move_resource`/`delete_resource` sont écrits dans la **même transaction Room** que la mutation parente ; l'écrivain (repository) et le worker partagent ce contrat. En mode local (pas de token), le worker est no-op. **Gate `processed`** : un fichier SAF nouvellement découvert est importé localement mais poussé seulement au « garder » (`markProcessed`, qui enqueue le `create_resource` dans la même transaction, une seule fois via `hasCreateOperation`) — d'où le filtrage des documents par l'utilisateur avant toute synchro. Un `delete_resource` n'est journalisé que si un `create_resource` a existé (jamais pour un fichier non poussé).
- Pas de cache de permissions côté client en V1 : le serveur reste la source de vérité pour l'accès (`GET /sync/permissions` non consommé par le client Kotlin). - Pas de cache de permissions côté client en V1 : le serveur reste la source de vérité pour l'accès (`GET /sync/permissions` non consommé par le client Kotlin).
## API Contract (V1) ## API Contract (V1)
@@ -79,17 +79,16 @@ class ProcessedGateTest {
fun nouveau_fichier_saf_non_traite_aucune_op_enqueue() = runTest { fun nouveau_fichier_saf_non_traite_aucune_op_enqueue() = runTest {
saveLocal(processed = false, uri = FILE_URI) saveLocal(processed = false, uri = FILE_URI)
val ops = opsDao.selectPending(20) val ops = selectOps(RESOURCE_ID)
assertTrue("un fichier SAF non traité ne doit rien pousser", ops.isEmpty()) assertTrue("un fichier SAF non traité ne doit rien pousser", ops.isEmpty())
val file = fileDao.getByUri(FILE_URI)!! assertEquals(false, fileDao.getByResourceId(RESOURCE_ID)!!.processed)
assertEquals(false, file.processed)
} }
@Test @Test
fun fichier_scan_deja_traite_pousse_immediatement() = runTest { fun fichier_scan_deja_traite_pousse_immediatement() = runTest {
saveLocal(processed = true) saveLocal(processed = true)
val ops = opsDao.selectPending(20) val ops = selectOps(RESOURCE_ID)
assertEquals(1, ops.size) assertEquals(1, ops.size)
assertEquals(PendingOperationType.CREATE_RESOURCE, ops[0].operation) assertEquals(PendingOperationType.CREATE_RESOURCE, ops[0].operation)
assertEquals("file", ops[0].resourceType) assertEquals("file", ops[0].resourceType)
@@ -99,27 +98,25 @@ class ProcessedGateTest {
@Test @Test
fun garder_enqueue_create_resource_une_seule_fois() = runTest { fun garder_enqueue_create_resource_une_seule_fois() = runTest {
saveLocal(processed = false, uri = FILE_URI) saveLocal(processed = false)
val file = fileDao.getByUri(FILE_URI)!!
fileRepository.markProcessed(file.resourceId) fileRepository.markProcessed(RESOURCE_ID)
assertEquals(1, selectOps(file.resourceId).size) assertEquals(1, selectOps(RESOURCE_ID).size)
assertEquals(true, fileDao.getByResourceId(file.resourceId)!!.processed) assertEquals(true, fileDao.getByResourceId(RESOURCE_ID)!!.processed)
// Idempotent : re-garder (double tap) ne re-enqueue pas. // Idempotent : re-garder (double tap) ne re-enqueue pas.
fileRepository.markProcessed(file.resourceId) fileRepository.markProcessed(RESOURCE_ID)
assertEquals(1, selectOps(file.resourceId).size) assertEquals(1, selectOps(RESOURCE_ID).size)
} }
@Test @Test
fun garder_un_fichier_deja_pousse_ne_re_enqueue_pas() = runTest { fun garder_un_fichier_deja_pousse_ne_re_enqueue_pas() = runTest {
// Fichier scan (déjà `processed`, create déjà enqueue à l'ingestion). // Fichier scan (déjà `processed`, create déjà enqueue à l'ingestion).
saveLocal(processed = true) saveLocal(processed = true)
val file = fileDao.getByResourceId(RESOURCE_ID)!! assertEquals(1, selectOps(RESOURCE_ID).size)
assertEquals(1, selectOps(file.resourceId).size)
fileRepository.markProcessed(file.resourceId) fileRepository.markProcessed(RESOURCE_ID)
assertEquals(1, selectOps(file.resourceId).size) assertEquals(1, selectOps(RESOURCE_ID).size)
} }
// --- tout marquer (markAllProcessed) ------------------------------------ // --- tout marquer (markAllProcessed) ------------------------------------
@@ -163,8 +160,11 @@ class ProcessedGateTest {
// --- fixtures ------------------------------------------------------------ // --- fixtures ------------------------------------------------------------
private fun saveLocal(processed: Boolean, uri: String = "content://tree/file", resourceId: String = RESOURCE_ID) { private suspend fun saveLocal(
runBlockingSafe { processed: Boolean,
uri: String = "content://tree/file",
resourceId: String = RESOURCE_ID,
) {
fileRepository.saveLocalFile( fileRepository.saveLocalFile(
input = SaveFileInput( input = SaveFileInput(
uri = uri, uri = uri,
@@ -178,17 +178,16 @@ class ProcessedGateTest {
folderResourceId = FOLDER, folderResourceId = FOLDER,
) )
} }
}
private fun selectOps(resourceId: String): List<PendingOperationEntity> = private suspend fun selectOps(resourceId: String): List<PendingOperationEntity> =
runBlockingSafe { opsDao.selectPending(100) }.filter { it.resourceId == resourceId } opsDao.selectPending(100).filter { it.resourceId == resourceId }
private fun op( private fun op(
resourceId: String, resourceId: String,
operation: String, operation: String,
status: String, status: String,
) = PendingOperationEntity( ) = PendingOperationEntity(
operationId = operationIdSeq.format(), operationId = String.format("%032x", opSeq++),
resourceId = resourceId, resourceId = resourceId,
resourceType = "file", resourceType = "file",
operation = operation, operation = operation,
@@ -199,8 +198,6 @@ class ProcessedGateTest {
) )
private var opSeq = 0 private var opSeq = 0
private val operationIdSeq: String get() = String.format("%032x", opSeq++)
private fun <T> runBlockingSafe(block: suspend () -> T): T = kotlinx.coroutines.runBlocking { block() }
private companion object { private companion object {
const val NOW = 1_700_000_000_000L const val NOW = 1_700_000_000_000L