From 1460fdfe0e1ad85fbfd2186c6350e6c6650216fb Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 22:08:29 +0200 Subject: [PATCH] outbox integration --- AGENTS.md | 27 +-- docs/api-v1.md | 14 +- mobile-kotlin/app/build.gradle.kts | 3 + .../vaultdrop/mobile/VaultDropApplication.kt | 13 +- .../mobile/data/local/AppDatabase.kt | 13 +- .../data/local/dao/PendingOperationDao.kt | 38 +++++ .../local/entity/PendingOperationEntity.kt | 73 +++++++++ .../mobile/data/local/migration/Migrations.kt | 34 +++- .../vaultdrop/mobile/data/remote/ApiClient.kt | 12 ++ .../mobile/data/remote/ApiService.kt | 15 ++ .../vaultdrop/mobile/data/remote/dto/Dtos.kt | 41 +++++ .../mobile/data/repository/FileRepository.kt | 34 +++- .../data/repository/FolderRepository.kt | 20 ++- .../data/repository/OutboxRepository.kt | 108 ++++++++++++ .../com/vaultdrop/mobile/di/DatabaseModule.kt | 4 + .../mobile/features/saf/FileMover.kt | 2 +- .../mobile/features/saf/SafFileDeleter.kt | 13 +- .../mobile/features/sync/OutboxSyncWorker.kt | 155 ++++++++++++++++++ .../mobile/features/sync/SyncViewModel.kt | 6 + .../vaultdrop/mobile/ui/auth/AuthViewModel.kt | 8 + mobile-kotlin/gradle/libs.versions.toml | 4 + 21 files changed, 607 insertions(+), 30 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/PendingOperationEntity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/OutboxSyncWorker.kt diff --git a/AGENTS.md b/AGENTS.md index d56caca..f3ea020 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Status -`backend/` (Go) et `mobile-kotlin/` (Kotlin/Android) ont du scaffolding en place. Le client mobile est **local-first** : il reflète le SAF dans une base Room (v5, migrations, DAO) et n'utilise le serveur que pour s'enregistrer, se connecter et lister fichiers/dossiers. Pas de poussée cloud (outbox), pas d'upload, pas d'OCR ni de recherche serveur côté client en V1. +`backend/` (Go) et `mobile-kotlin/` (Kotlin/Android) ont du scaffolding en place. Le client mobile est **local-first** : il reflète le SAF dans une base Room (v7, migrations, DAO) et pousse les **métadonnées** de ses mutations locales via un **outbox transactionnel** (`pending_operations` + worker WorkManager → `POST /sync/ops`). Pas d'upload physique, pas d'OCR ni de recherche serveur côté client en V1. ## Architecture @@ -37,7 +37,7 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest - `service/` — business logic (permissions, upload, create folder, move, **sync outbox + snapshot**, bootstrap admin) - `handlers/` — HTTP handlers (health, devices register, **auth/login**, **users resolve + change password**, files CRUD/upload/search, folders, **sync/ops + sync/permissions, ocr/jobs** — réels) ; middleware `RequireAuth` - `repository/` — Postgres persistence (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, search, move, rename, root-name unique index, `ListOwned` pour le snapshot ; `repository.Devices` : Upsert, Exists, MarkUser ; `repository.Operations` : trace outbox idempotente `(device_id, operation_id)` ; `repository.OcrJobs` : jobs queued→processing→done/failed ; `repository.Users` : GetByUsernameNormalized, ResolveExact, UpdatePassword) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`) -- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`, 000001→000007) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible) ; `dbtest/` — helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down) +- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`, 000001→000008) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible) ; `dbtest/` — helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down) - `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`) - `pkg/api/` — response helpers (`response.go`) ; `pkg/auth/` — tokens **paseto v4-local** (subject = `user_id`, claim = `device_id`, TTL 7j, voir `docs/api-v1.md`) ; `pkg/passwd/` — hashing/vérification bcrypt (timing-equal) - File uploads stored in `backend/uploads/` @@ -47,37 +47,40 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest ## Frontend Structure (mobile-kotlin) -- Entry point: `app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt` + `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/local/` — Room SQLite (DB `dot.db`, **version 6**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`) : entités Folder/File/UserPreference, DAO, tri (`FileOrdering`) ; flag `processed` sur `files` (mode review « traiter », local au device — le backlog est marqué traité à la migration v6) + - `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/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` + - `data/repository/` — `FolderRepository`, `FileRepository`, `AuthRepository`, `OutboxRepository` (enqueue `create_resource`/`move_resource`/`delete_resource`, écrivains dans la même transaction Room que la mutation parente) - `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` - - `saf/` — `SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule si échec ; pas de poussée serveur) + - `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 + - `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/SyncViewModel.kt` — état du sync exposé à l'UI - `auth/` — session (login user + token paseto) : `SessionManager`, `SecureTokenStore`, `TokenProvider` - `ui/` — écrans Compose : `folderlist`, `folderdetail`, `document` (contenu PDF/image/texte + placeholder cloud-only), `search` (**recherche locale** via Room, sans endpoint serveur), `settings` (URL serveur + thème), `pdfbuilder` (multi-select → génération PDF), `auth` (login, mode local), `components`, `theme` - `domain/` — stores de préférences: `DeviceIdentity`, `ActiveUserStore`, `LocalModeStore`, `ServerConfigStore`, `ThemePreferenceStore`, `GenerateId` (identifiants 32-hex) - `di/` — modules Hilt (`AppModule`, `DatabaseModule`, `NetworkModule`) - Base URL serveur **configurée à l'exécution** dans les Réglages (`ServerConfigStore`, persistée en `user_preferences`), défaut `http://10.0.2.2:8080/api/v1` (pas de variable d'env) -- Client **local-first** : aucune poussée cloud (pas d'outbox, pas de `sync/ops`), juste `GET /files/folders`, `GET /files`, `POST /devices`, `POST /auth/login` ; téléchargement/upload hors scope +- Client **local-first** : lecture via `GET /files/folders`, `GET /files` ; mutations locales poussées via l'outbox `POST /sync/ops` (`create_resource`/`move_resource`/`delete_resource`) ; `POST /devices`, `POST /auth/login` ; download/upload physique hors scope ## Data Conventions - Canonical identity for folders/files is `resource_id`: opaque 32-hex, generated locally (`GenerateId`), never reused. The physical `uri` (SAF) is nullable (NULL = cloud-only) and is the reconciliation key for the SAF walk (unique index, NULLs distincts). +- Similarly, `pending_operations.operation_id` is a client-generated 32-hex (`GenerateId`), the idempotency key of the outbox `UNIQUE(device_id, operation_id)` lettre contre les rejeux du worker. - `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. - Client storage: `user_preferences` (clé/valeur) pour l'identité device, le compte actif, l'URL serveur, le thème, le mode local. -- Pas d'outbox ni de cache de permissions côté client en V1 : le serveur reste la source de vérité, le client ne pousse rien (hors scope). +- 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. +- 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) - **Le client mobile est le contrat**: les formes d'endpoints dans `mobile-kotlin/.../data/remote/ApiService.kt` + `dto/Dtos.kt` sont autoritatives et doivent matcher exactement ; le serveur ne renégocie pas. Spec consolidée: `docs/api-v1.md`. - **Identity (user-first, V1 finale)**: le device s'enregistre (`POST /devices`, `{ "deviceId" }` seul, sans token), puis `POST /auth/login` (`username` + `password` + `device_id`) émet le seul token paseto **v4-local** — subject = `user_id` (autorise, scoping de toutes les ressources), claim `device_id` (porté, non autorisant seul), **TTL 7j sans refresh**. À expiration, le client re-logine. - **Bootstrap**: au premier démarrage, si `users` est vide, `ADMIN_USERNAME`/`ADMIN_PASSWORD` (env) créent le premier admin ; absents → le serveur refuse de démarrer. L'env n'écrase jamais un compte existant. Usernames résolus sur `username_normalized` (lowercase + trim), `GET /users/resolve` exact uniquement (pas d'énumération). -- **Identifiers**: `resource_id` / `device_user_id` / `user_id` / share-link `token` sont des **lowercase 32-hex** TEXT (`^[0-9a-f]{32}$`, CHECK-enforced), stockés tels quels côté serveur (pas de conversion UUID). Le mobile génère toujours 32-hex. -- **Outbox idempotence + ordering**: le serveur applique les batchs de `pending_operations` **séquentiellement**, s'arrête à la première erreur non-idempotente et retourne l'index atteint. (Endpoint `POST /sync/ops` existant côté serveur ; pas encore consommé par le client Kotlin.) +- **Identifiers**: `resource_id` / `device_user_id` / `user_id` / share-link `token` / `operation_id` sont des **lowercase 32-hex** TEXT (`^[0-9a-f]{32}$`, CHECK-enforced), stockés tels quels côté serveur (pas de conversion UUID). Le mobile génère toujours 32-hex. +- **Outbox idempotence + ordering**: le serveur applique les batchs de `pending_operations` **séquentiellement**, s'arrête à la première erreur non-idempotente et retourne l'index atteint. Consommé par le client Kotlin (`OutboxSyncWorker` → `POST /sync/ops`, batch 20, dead-letter immédiat sur 4xx non-idempotente, purge > 7 jours). `create_resource` porte le `parentResourceId` optionnel (racine si absent) — le walk SAF garantit parent avant enfant dans l'ordre d'`id`. - **Permissions snapshot**: le serveur pousse des snapshots `resource_permissions` (`effective_access` ranking viewer < commenter < editor < owner, `inherit`, `expires_at`, TTL 24h → read-only downgrade). (Endpoint existant côté serveur ; pas encore consommé par le client Kotlin.) ## Non-Goals (V1) @@ -86,7 +89,7 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest - On-device OCR - Full multi-tenant federation / public discovery - Multi-writer sync conflicts (single-owner device identity) -- Client→cloud push (outbox, permissions, OCR jobs, upload) côté mobile Kotlin +- Upload physique client→cloud (métadonnées seules via l'outbox, download, OCR jobs, permissions) côté mobile Kotlin ## References diff --git a/docs/api-v1.md b/docs/api-v1.md index f01f670..bd0ebf4 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -84,9 +84,9 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: { "operations": [ { - "operation_id": 42, // = id client (pending_operations.id) + "operation_id": "…32-hex", // généré par le client (GenerateId), jamais réutilisé "ref_type": "resource", // "resource" | "share" | "share_link" - "ref_id": 7, // id local de la ligne share/share_link (sinon null) + "ref_id": null, // id local de la ligne share/share_link (sinon null) "resource_id": "…32-hex", // ressource cible "resource_type": "folder", // "folder" | "file" "operation": "create_resource", @@ -97,15 +97,15 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: ``` - `operation` ∈ `create_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link` (cf. `PendingOperationType` mobile). -- L'idempotence outbox reste **par device** : `UNIQUE(device_id, operation_id)` (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide. +- **Identifiants** : `operation_id` est un **TEXT 32-hex** généré par le client (`^[0-9a-f]{32}$`, CHECK-enforced depuis la migration `000008`), distinct de `resource_id`. L'idempotence outbox reste **par device** : `UNIQUE(device_id, operation_id)` (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide. - **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index. -- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": int, "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer). -- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5). **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client. +- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": "…32-hex", "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer). +- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; **dead-letter immédiat** sur erreur 4xx non-idempotente (`failed`, non resélectionné ; `attempts` reste un compteur diagnostic, pas un seuil) — seul le transitoire (`NETWORK_ERROR`/5xx) est rejoué avec backoff. **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client. - Sémantique d'application (côté serveur) : - - `create_resource` : crée la ressource ; **déjà présente → no-op** (rejeu idempotent). `payload.name` obligatoire. + - `create_resource` : crée la ressource ; **déjà présente → no-op** (rejeu idempotent). `payload.name` obligatoire ; `payload.parentResourceId` (32-hex, optionnel) = dossier parent — absent → racine. **Parent inexistant → `NOT_FOUND`** (cohérent avec `move_resource`). Ordre garanti par construction client : le walk SAF émet les `create` des dossiers (ordre préfixe) avant ceux des fichiers, dans la même transaction Room → `id ASC` = parent avant enfant. - `update_metadata` / `move_resource` : ressource absente → **no-op** (état terminal atteint) ; dossier cible de `move_resource` absent → `NOT_FOUND` ; déplacement dans soi-même → `INVALID_REQUEST`. - `delete_resource` : **idempotent** — suppression d'une ressource absente = succès. - - Validation (deuxième champ `operation_id`, hex32 pour `resource_id`, enum `operation`) → échec `INVALID_REQUEST` avec arrêt du batch. + - Validation (deuxième champ `operation_id`, hex32 pour `resource_id` **et** `operation_id`, enum `operation`) → échec `INVALID_REQUEST` avec arrêt du batch. - Nom déjà pris (même parent, ou à la racine) → échec `NAME_CONFLICT`.** ### 6.2 Snapshot — `GET /sync/permissions?after=` diff --git a/mobile-kotlin/app/build.gradle.kts b/mobile-kotlin/app/build.gradle.kts index 3e8b747..31aa8a5 100644 --- a/mobile-kotlin/app/build.gradle.kts +++ b/mobile-kotlin/app/build.gradle.kts @@ -64,6 +64,9 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.retrofit) implementation(libs.retrofit.converter.moshi) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt index 9e55fff..1a118e9 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt @@ -1,11 +1,22 @@ package com.vaultdrop.mobile import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +import javax.inject.Inject @HiltAndroidApp -class VaultDropApplication : Application() { +class VaultDropApplication : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() override fun onCreate() { super.onCreate() diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index f57b5d2..81510f1 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -4,19 +4,25 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.vaultdrop.mobile.data.local.dao.FileDao import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity /* * DB SQLite locale, `dot.db` (même nom que la version Expo). * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; - * v5: created_in_app sur folders ; v6: processed sur files (mode review). + * v5: created_in_app sur folders ; v6: processed sur files (mode review) ; + * v7: pending_operations (outbox). */ @Database( - entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], - version = 6, + entities = [ + FolderEntity::class, UserPreferenceEntity::class, FileEntity::class, + PendingOperationEntity::class, + ], + version = 7, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -24,4 +30,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun folderDao(): FolderDao abstract fun userPreferenceDao(): UserPreferenceDao abstract fun fileDao(): FileDao + abstract fun pendingOperationDao(): PendingOperationDao } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt new file mode 100644 index 0000000..06860ac --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt @@ -0,0 +1,38 @@ +package com.vaultdrop.mobile.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity + +@Dao +interface PendingOperationDao { + + /** Prochaines opérations à pousser, strictement par ordre de création. */ + @Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit") + suspend fun selectPending(limit: Int): List + + @Insert + suspend fun insert(op: PendingOperationEntity): Long + + @Query("UPDATE pending_operations SET status = 'synced', updated_at = :now WHERE id = :id") + suspend fun markSynced(id: Long, now: Long) + + /** Échec avec bump du compteur de tentatives (dead-letter après MAX_PENDING_ATTEMPTS). */ + @Query("UPDATE pending_operations SET status = 'failed', attempts = attempts + 1, updated_at = :now WHERE id = :id") + suspend fun markFailed(id: Long, now: Long) + + /** + * Invalidation en cascade : un `create_resource` définitivement échoué rend + * fautives toutes les ops en attente qui ciblent la même ressource (filles). + */ + @Query("UPDATE pending_operations SET status = 'failed', updated_at = :now WHERE resource_id = :resourceId AND status = 'pending'") + suspend fun failDescendants(resourceId: String, now: Long) + + /** Rétention : purge des ops synchronisées plus anciennes que `cutoff`. */ + @Query("DELETE FROM pending_operations WHERE status = 'synced' AND updated_at < :cutoff") + suspend fun purgeSynced(cutoff: Long) + + @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'") + suspend fun countPending(): Int +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/PendingOperationEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/PendingOperationEntity.kt new file mode 100644 index 0000000..ba02317 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/PendingOperationEntity.kt @@ -0,0 +1,73 @@ +package com.vaultdrop.mobile.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Outbox locale (transactional outbox pattern) — file des mutations locales + * à pousser vers le serveur via `POST /sync/ops`. + * + * `id` = PK auto-incr Room (interne : tri, statut) ; `operationId` = UUID + * 32-hex généré côté client, c'est la valeur exposée en `operation_id` du + * protocole (cf. docs/api-v1.md §6.1). L'idempotence serveur s'appuie sur + * `UNIQUE(device_id, operation_id)`. + * + * `payload` = métadonnées JSON légères uniquement — jamais de blobs binaires + * (l'upload physique reste un pipeline multipart séparé, hors outbox). + */ +@Entity( + tableName = "pending_operations", + indices = [ + Index(value = ["operation_id"], unique = true), + Index(value = ["status"]), + Index(value = ["resource_id"]), + ], +) +data class PendingOperationEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + @ColumnInfo(name = "operation_id") + val operationId: String, + @ColumnInfo(name = "ref_type") + val refType: String? = null, + @ColumnInfo(name = "ref_id") + val refId: Long? = null, + @ColumnInfo(name = "resource_id") + val resourceId: String? = null, + @ColumnInfo(name = "resource_type") + val resourceType: String? = null, + @ColumnInfo(name = "operation") + val operation: String, + @ColumnInfo(name = "payload") + val payload: String, + @ColumnInfo(name = "status") + val status: String = PendingOpStatus.PENDING, + @ColumnInfo(name = "attempts") + val attempts: Int = 0, + @ColumnInfo(name = "created_at") + val createdAt: Long, + @ColumnInfo(name = "updated_at") + val updatedAt: Long, +) + +/** Statuts d'une op outbox — `pushStatus` du contrat sync (pending/synced/failed). */ +object PendingOpStatus { + const val PENDING = "pending" + const val SYNCED = "synced" + const val FAILED = "failed" +} + +/** Types d'opérations de l'outbox — miroir de `PendingOperationType` (docs/api-v1.md §6.1). */ +object PendingOperationType { + const val CREATE_RESOURCE = "create_resource" + const val UPDATE_METADATA = "update_metadata" + const val DELETE_RESOURCE = "delete_resource" + const val MOVE_RESOURCE = "move_resource" + const val SHARE = "share" + const val REVOKE_SHARE = "revoke_share" + const val UPDATE_SHARE = "update_share" + const val CREATE_LINK = "create_link" + const val REVOKE_LINK = "revoke_link" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index 7a5844e..c175d51 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -17,6 +17,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase * v6 : ajout colonne `processed` sur `files` (mode review « traiter »). Le * backlog existant est marqué traité à la migration : seuls les fichiers * découverts après la mise à jour entrent dans la file de review. + * v7 : table `pending_operations` (outbox) — file des mutations locales à + * pousser vers `POST /sync/ops` (uuid 32-hex client-generated, cf. + * docs/api-v1.md §6.1). */ object Migrations { @@ -87,5 +90,34 @@ object Migrations { } } - val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + private val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `pending_operations` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `operation_id` TEXT NOT NULL, + `ref_type` TEXT, + `ref_id` INTEGER, + `resource_id` TEXT, + `resource_type` TEXT, + `operation` TEXT NOT NULL, + `payload` TEXT NOT NULL, + `status` TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'synced', 'failed')), + `attempts` INTEGER NOT NULL DEFAULT 0, + `created_at` INTEGER NOT NULL, + `updated_at` INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_pending_operations_operation_id` ON `pending_operations` (`operation_id`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_operations_status` ON `pending_operations` (`status`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_operations_resource_id` ON `pending_operations` (`resource_id`)") + } + } + + val ALL: Array = arrayOf( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + ) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt index 3646b2f..2ea9382 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt @@ -9,6 +9,10 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto +import com.vaultdrop.mobile.data.remote.dto.SyncOpDto +import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest +import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import okio.IOException import retrofit2.Response import timber.log.Timber @@ -59,6 +63,14 @@ class ApiClient @Inject constructor( unwrap({ apiService.login(LoginRequestDto(username, password, deviceId)) }, skipUnauthorizedHandling = true) + /** Push outbox : applique un batch d'opérations, séquentiel et idempotent. */ + suspend fun syncOps(operations: List): SyncOpsResult = + unwrap({ apiService.syncOps(SyncOpsRequest(operations)) }) + + /** Snapshot des permissions effectives (delta si `after` ms fourni). */ + suspend fun syncPermissions(after: Long? = null): List = + unwrap({ apiService.syncPermissions(after) }) + private suspend fun unwrap( call: suspend () -> Response>, skipUnauthorizedHandling: Boolean = false, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt index 552277a..1d89dca 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt @@ -6,6 +6,9 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto +import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest +import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -41,4 +44,16 @@ interface ApiService { suspend fun login( @Body body: LoginRequestDto, ): Response> + + /** Outbox client→serveur : applique un batch séquentiel, idempotent par device. */ + @POST("sync/ops") + suspend fun syncOps( + @Body body: SyncOpsRequest, + ): Response> + + /** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */ + @GET("sync/permissions") + suspend fun syncPermissions( + @Query("after") after: Long? = null, + ): Response>> } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt index 582a048..0a8c9fc 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt @@ -80,4 +80,45 @@ data class LoginResponseDto( @Json(name = "token") val token: String, @Json(name = "expires_at") val expiresAt: Long, @Json(name = "user") val user: UserDto, +) + +/** Outbox — une op du batch `POST /sync/ops` (docs/api-v1.md §6.1). */ +data class SyncOpDto( + @Json(name = "operation_id") val operationId: String, + @Json(name = "ref_type") val refType: String? = null, + @Json(name = "ref_id") val refId: Long? = null, + @Json(name = "resource_id") val resourceId: String? = null, + @Json(name = "resource_type") val resourceType: String? = null, + @Json(name = "operation") val operation: String, + @Json(name = "payload") val payload: Map? = null, +) + +data class SyncOpsRequest( + @Json(name = "operations") val operations: List, +) + +/** Réponse de `POST /sync/ops` : `applied` = index de la prochaine op à envoyer. */ +data class SyncOpsResult( + @Json(name = "applied") val applied: Int, + @Json(name = "failed") val failed: SyncFailedDto? = null, +) + +/** Première erreur non-idempotente du batch (arrêt du serveur). */ +data class SyncFailedDto( + @Json(name = "operation_id") val operationId: String, + @Json(name = "code") val code: String, + @Json(name = "message") val message: String, +) + +/** Snapshot `GET /sync/permissions` (docs/api-v1.md §6.2). */ +data class ResourcePermissionDto( + @Json(name = "resource_id") val resourceId: String, + @Json(name = "resourceType") val resourceType: String, + @Json(name = "effectiveAccess") val effectiveAccess: String, + @Json(name = "inherit") val inherit: Boolean, + @Json(name = "ownerId") val ownerId: String? = null, + @Json(name = "sharedById") val sharedById: Any? = null, + @Json(name = "expiresAt") val expiresAt: Any? = null, + @Json(name = "cachedAt") val cachedAt: Long, + @Json(name = "updatedAt") val updatedAt: Long, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index df3fdc8..2c5d4f4 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -1,5 +1,7 @@ package com.vaultdrop.mobile.data.repository +import androidx.room.withTransaction +import com.vaultdrop.mobile.data.local.AppDatabase import com.vaultdrop.mobile.data.local.dao.FileDao import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileStatus @@ -17,12 +19,17 @@ import javax.inject.Singleton * * Les fichiers renvoyés par le serveur sont cloud-only (uri = NULL) : * aucune copie physique locale, mirror de `saveFile(..., syncStatus='cloud')`. + * + * L'antichambre outbox (`create_resource`/`move_resource`) est écrite dans la + * MÊME transaction que la mutation Room (pattern transactional outbox). */ @Singleton class FileRepository @Inject constructor( private val fileDao: FileDao, private val apiClient: ApiClient, private val generateId: GenerateId, + private val appDatabase: AppDatabase, + private val outboxRepository: OutboxRepository, ) { /** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */ @@ -97,7 +104,20 @@ class FileRepository @Inject constructor( addedAt = existing?.addedAt ?: now, updatedAt = now, ) - fileDao.upsert(entity) + appDatabase.withTransaction { + fileDao.upsert(entity) + if (existing == null) { + // Nouveau fichier physique → le pousser vers le serveur (métadonnées). + outboxRepository.enqueueCreateResource( + resourceId = entity.resourceId, + resourceType = "file", + name = entity.name, + parentResourceId = entity.folderResourceId, + mimeType = entity.mimeType, + extension = entity.extension, + ) + } + } return entity } @@ -108,8 +128,16 @@ class FileRepository @Inject constructor( /** Met à jour la cible dossier d'un fichier (déplacement local / cloud-only). */ suspend fun applyMove(resourceId: String, folderId: String, newUri: String?) { val now = System.currentTimeMillis() - fileDao.moveToFolder(listOf(resourceId), folderId, now) - if (newUri != null) fileDao.updateUri(resourceId, newUri) + appDatabase.withTransaction { + fileDao.moveToFolder(listOf(resourceId), folderId, now) + if (newUri != null) fileDao.updateUri(resourceId, newUri) + // Déplacement reflété localement ←→ poussé vers le serveur. + outboxRepository.enqueueMoveResource( + resourceId = resourceId, + resourceType = "file", + toFolderResourceId = folderId, + ) + } } /** diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index 250d3a7..1594196 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -1,5 +1,7 @@ package com.vaultdrop.mobile.data.repository +import androidx.room.withTransaction +import com.vaultdrop.mobile.data.local.AppDatabase import com.vaultdrop.mobile.data.local.dao.FolderDao import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderStatus @@ -14,6 +16,9 @@ import javax.inject.Singleton /** * Miroir de `services/db/repositories/folders.ts`. Local-first : l'UI lit Room * ; le réseau sert de source de rafraîchissement (snapshot cloud). + * + * L'antichambre outbox (`create_resource` pour un dossier SAF nouvellement + * découvert/créé) est écrite dans la MÊME transaction que l'upsert Room. */ @Singleton class FolderRepository @Inject constructor( @@ -21,6 +26,8 @@ class FolderRepository @Inject constructor( private val apiClient: ApiClient, private val generateId: GenerateId, private val deviceIdentity: DeviceIdentity, + private val appDatabase: AppDatabase, + private val outboxRepository: OutboxRepository, ) { fun observeRootFolders(): Flow> = folderDao.observeRootFolders() @@ -103,7 +110,18 @@ class FolderRepository @Inject constructor( addedAt = existing?.addedAt ?: now, updatedAt = now, ) - folderDao.upsert(entity) + appDatabase.withTransaction { + folderDao.upsert(entity) + if (existing == null) { + // Nouvelle ressource physique → la pousser vers le serveur. + outboxRepository.enqueueCreateResource( + resourceId = entity.resourceId, + resourceType = "folder", + name = entity.name, + parentResourceId = entity.parentResourceId, + ) + } + } return entity } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt new file mode 100644 index 0000000..dd8d5d1 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt @@ -0,0 +1,108 @@ +package com.vaultdrop.mobile.data.repository + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao +import com.vaultdrop.mobile.data.local.entity.PendingOpStatus +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationType +import com.vaultdrop.mobile.domain.GenerateId +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Antichambre de l'outbox : enregistre les mutations locales (métadonnées JSON + * uniquement) à pousser vers `POST /sync/ops`. + * + * **Contrat d'atomicité** : `enqueue` doit être appelé dans le MÊME bloc + * `AppDatabase.withTransaction { … }` que la mutation Room correspondante — + * la file et l'état local évoluent ensemble (pattern transactional outbox). + */ +@Singleton +class OutboxRepository @Inject constructor( + private val pendingOperationDao: PendingOperationDao, + private val generateId: GenerateId, + moshi: Moshi, +) { + + private val payloadAdapter: JsonAdapter> = moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java), + ) + + /** + * Enregistre une op à pousser. Retourne l'`operationId` 32-hex (exposé en + * `operation_id` du protocole). À appeler dans un `withTransaction`. + */ + suspend fun enqueue( + operation: String, + resourceId: String, + resourceType: String, + payload: Map = emptyMap(), + refType: String? = null, + refId: Long? = null, + ): String { + val operationId = generateId.newResourceId() + val now = System.currentTimeMillis() + pendingOperationDao.insert( + PendingOperationEntity( + operationId = operationId, + refType = refType, + refId = refId, + resourceId = resourceId, + resourceType = resourceType, + operation = operation, + payload = payloadAdapter.toJson(payload), + status = PendingOpStatus.PENDING, + createdAt = now, + updatedAt = now, + ), + ) + return operationId + } + + /** Raccourci `create_resource` (folder/file) — docs/api-v1.md §6.1. */ + suspend fun enqueueCreateResource( + resourceId: String, + resourceType: String, + name: String, + parentResourceId: String? = null, + mimeType: String? = null, + extension: String? = null, + ): String = enqueue( + operation = PendingOperationType.CREATE_RESOURCE, + resourceId = resourceId, + resourceType = resourceType, + payload = buildMap { + put("name", name) + parentResourceId?.let { put("parentResourceId", it) } + mimeType?.let { put("mimeType", it) } + extension?.let { put("extension", it) } + }, + ) + + /** Raccourci `move_resource` — payload `{ toFolderResourceId }`. */ + suspend fun enqueueMoveResource( + resourceId: String, + resourceType: String, + toFolderResourceId: String, + ): String = enqueue( + operation = PendingOperationType.MOVE_RESOURCE, + resourceId = resourceId, + resourceType = resourceType, + payload = mapOf("toFolderResourceId" to toFolderResourceId), + ) + + /** Raccourci `delete_resource` — idempotent côté serveur. */ + suspend fun enqueueDeleteResource( + resourceId: String, + resourceType: String, + ): String = enqueue( + operation = PendingOperationType.DELETE_RESOURCE, + resourceId = resourceId, + resourceType = resourceType, + ) + + /** Nombre d'ops en attente de push (stats UI optionnelles). */ + suspend fun countPending(): Int = pendingOperationDao.countPending() +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt index e77e76d..af2921e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt @@ -5,6 +5,7 @@ import androidx.room.Room import com.vaultdrop.mobile.data.local.AppDatabase import com.vaultdrop.mobile.data.local.dao.FileDao import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao import com.vaultdrop.mobile.data.local.migration.Migrations import dagger.Module @@ -33,4 +34,7 @@ object DatabaseModule { @Provides fun provideFileDao(db: AppDatabase): FileDao = db.fileDao() + + @Provides + fun providePendingOperationDao(db: AppDatabase): PendingOperationDao = db.pendingOperationDao() } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt index 44aeb6c..60a3392 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileMover.kt @@ -22,7 +22,7 @@ import javax.inject.Singleton * bas de gamme : métadonnée seule (`folderResourceId`), la marche suivante * re-réconciliera. * - fichier cloud-only → mise à jour de la métadonnée de dossier uniquement - * (pas de poussée serveur en V1 — pas d'outbox). + * (`applyMove` journalise aussi un `move_resource` dans l'outbox). */ @Singleton class FileMover @Inject constructor( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt index 1600279..2e03627 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt @@ -4,8 +4,11 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import androidx.room.withTransaction +import com.vaultdrop.mobile.data.local.AppDatabase import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.OutboxRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -20,6 +23,8 @@ import javax.inject.Singleton * disparaît aussi de l'arborescence (et donc du prochain sync). En cas de * succès, la ligne Room est masquée (`exists = 0`) : cohérent avec la * réconciliation (jamais de DELETE SQL), idempotent face à la marche 30s. + * La suppression est aussi journalisée dans l'outbox (`delete_resource`), + * dans la même transaction que le masquage Room. * * Retourne `false` si le provider refuse la suppression (permission ou pas de * geste delete) : dans ce cas le fichier reste dans la file de review. @@ -28,6 +33,8 @@ import javax.inject.Singleton class SafFileDeleter @Inject constructor( @ApplicationContext private val context: Context, private val fileRepository: FileRepository, + private val outboxRepository: OutboxRepository, + private val appDatabase: AppDatabase, ) { private val resolver: ContentResolver get() = context.contentResolver @@ -39,8 +46,12 @@ class SafFileDeleter @Inject constructor( DocumentsContract.deleteDocument(resolver, Uri.parse(uri)) }.onSuccess { deleted -> if (deleted) { + val now = System.currentTimeMillis() + appDatabase.withTransaction { + fileRepository.markMissing(file.resourceId, now) + outboxRepository.enqueueDeleteResource(file.resourceId, "file") + } Timber.d("deleted %s", uri) - fileRepository.markMissing(file.resourceId, System.currentTimeMillis()) } else { Timber.w("deleteDocument returned false for %s", uri) } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/OutboxSyncWorker.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/OutboxSyncWorker.kt new file mode 100644 index 0000000..fa693fe --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/OutboxSyncWorker.kt @@ -0,0 +1,155 @@ +package com.vaultdrop.mobile.features.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.vaultdrop.mobile.auth.TokenProvider +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationType +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiException +import com.vaultdrop.mobile.data.remote.dto.SyncOpDto +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit +import timber.log.Timber + +/** + * Worker de push de l'outbox : applique les batchs `pending_operations` vers + * `POST /sync/ops`, séquentiellement et dans l'ordre d'entrée en file. + * + * Garanties : + * - **single-flight** : enregistré via `enqueueUniqueWork(KEEP)` — jamais deux + * workers parallèles (ordonnancement chronologique préservé) ; + * - **no-op sans token** : mode local, rien à pousser (contrat syncOutbox) ; + * - **retry transient** : `NETWORK_ERROR`/5xx/timeout → `Result.retry()` + * (backoff exponentiel) sans bump du compteur d'attempts ; + * - **dead-letter permanent** : erreur 4xx non-idempotente → op passée en + * `failed` immédiatement (évite d'affamer la queue : le serveur s'arrête à + * la 1re erreur), et cascade si `create_resource` a échoué (toutes les ops + * filles en attente sur la même resource passent `failed`) ; + * - `attempts` est un compteur diagnostic (incrémenté à chaque dead-letter), + * pas un seuil : une op `failed` n'est plus resélectionnée. + * - push **avant** pull : le pull `GET /sync/permissions` (V2) s'appliquera + * après ce worker — jamais d'écrasement d'état optimiste. + */ +@HiltWorker +class OutboxSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val apiClient: ApiClient, + private val pendingOperationDao: PendingOperationDao, + private val tokenProvider: TokenProvider, + moshi: Moshi, +) : CoroutineWorker(appContext, workerParams) { + + private val payloadAdapter: JsonAdapter> = moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java), + ) + + override suspend fun doWork(): Result { + // Mode local : sans compte connecté, rien à pousser. + if (tokenProvider.current == null) return Result.success() + + while (true) { + val pending = pendingOperationDao.selectPending(BATCH_SIZE) + if (pending.isEmpty()) { + pendingOperationDao.purgeSynced(System.currentTimeMillis() - PURGE_AGE_MS) + return Result.success() + } + + val ops = pending.map { it.toSyncOpDto() } + val result = try { + apiClient.syncOps(ops) + } catch (e: ApiException) { + when { + e.code == "NETWORK_ERROR" || e.httpCode >= 500 -> { + // Transitoire : on retentera avec backoff, sans bump d'attempts. + return Result.retry() + } + e.httpCode == 401 -> { + // Token expiré/révoqué : le re-login se fera via l'UI. + return Result.success() + } + else -> { + // Erreur permanente : dead-letter la 1re op du batch. + val now = System.currentTimeMillis() + pendingOperationDao.markFailed(pending.first().id, now) + continue + } + } + } + + val now = System.currentTimeMillis() + // Ops appliquées par le serveur (indices < applied). + for (i in 0 until result.applied) { + pendingOperationDao.markSynced(pending[i].id, now) + Timber.d("outbox synced %s", pending[i].operationId) + } + + // Première erreur non-idempotente → dead-letter + cascade éventuelle. + result.failed?.let { fail -> + val failedIndex = pending.indexOfFirst { it.operationId == fail.operationId } + if (failedIndex >= 0) { + val failedOp = pending[failedIndex] + pendingOperationDao.markFailed(failedOp.id, now) + Timber.w("outbox failed %s (%s: %s)", fail.operationId, fail.code, fail.message) + if (failedOp.operation == PendingOperationType.CREATE_RESOURCE) { + failedOp.resourceId?.let { resourceId -> + pendingOperationDao.failDescendants(resourceId, now) + } + } + } + } + + // Boucle : d'autres batchs attendent → traité dans le même run. + } + } + + private fun PendingOperationEntity.toSyncOpDto(): SyncOpDto { + val payloadMap = runCatching { payloadAdapter.fromJson(payload) }.getOrNull() + return SyncOpDto( + operationId = operationId, + refType = refType, + refId = refId, + resourceId = resourceId, + resourceType = resourceType, + operation = operation, + payload = payloadMap, + ) + } + + companion object { + const val NAME = "outbox_sync" + private const val BATCH_SIZE = 20 + private const val PURGE_AGE_MS = 7 * 24 * 60 * 60 * 1000L + + /** + * Enregistre (idempotent) le worker avec contrainte réseau + backoff. + * `KEEP` : si un run est déjà planifié/en cours, on ne le remplace pas — + * single-flight garanti. + */ + fun enqueue(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP, request) + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt index 14ede44..6a9798f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -2,9 +2,11 @@ package com.vaultdrop.mobile.features.sync import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import android.content.Context import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -31,6 +33,7 @@ import javax.inject.Inject class SyncViewModel @Inject constructor( private val deviceSync: DeviceSync, private val folderRepository: FolderRepository, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private var loopJob: Job? = null @@ -54,6 +57,9 @@ class SyncViewModel @Inject constructor( runCatching { deviceSync.syncAll() } .onSuccess { results -> if (results.isNotEmpty()) Timber.d("syncAll: %s", results) + // Les nouvelles ressources découvertes sont dans l'outbox + // → drainer vers POST /sync/ops (single-flight via KEEP). + OutboxSyncWorker.enqueue(appContext) } .onFailure { e -> Timber.w(e, "syncAll failed, retrying later") } delay(INTERVAL_MS) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt index ad3e4e9..da9c292 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt @@ -1,12 +1,15 @@ package com.vaultdrop.mobile.ui.auth +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vaultdrop.mobile.auth.SessionManager import com.vaultdrop.mobile.data.remote.ApiClient import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.AuthRepository +import com.vaultdrop.mobile.features.sync.OutboxSyncWorker import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,6 +25,7 @@ class AuthViewModel @Inject constructor( private val authRepository: AuthRepository, private val sessionManager: SessionManager, private val apiClient: ApiClient, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _authState = MutableStateFlow(AuthState.Loading) @@ -43,6 +47,8 @@ class AuthViewModel @Inject constructor( else -> AuthState.SignedOut } authRepository.registerDevice() + // Session restaurée → drainer l'outbox laissée en attente. + OutboxSyncWorker.enqueue(appContext) } } @@ -58,6 +64,8 @@ class AuthViewModel @Inject constructor( sessionManager.save(response.token, response.user) _loginUiState.value = LoginUiState() _authState.value = AuthState.SignedIn(response.user) + // Connexion réussie → pousser les mutations locales en attente. + OutboxSyncWorker.enqueue(appContext) } .onFailure { e -> val error = when (e) { diff --git a/mobile-kotlin/gradle/libs.versions.toml b/mobile-kotlin/gradle/libs.versions.toml index ff15aac..8f4dc14 100644 --- a/mobile-kotlin/gradle/libs.versions.toml +++ b/mobile-kotlin/gradle/libs.versions.toml @@ -10,6 +10,7 @@ navigationCompose = "2.8.5" room = "2.6.1" hilt = "2.53.1" hiltNavigationCompose = "1.2.0" +workManager = "2.9.1" retrofit = "2.11.0" okhttp = "4.12.0" moshi = "1.15.1" @@ -37,6 +38,9 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }