outbox integration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+7
-7
@@ -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=<cached_at_ms>`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+38
@@ -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<PendingOperationEntity>
|
||||
|
||||
@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
|
||||
}
|
||||
+73
@@ -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"
|
||||
}
|
||||
+33
-1
@@ -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<Migration> = 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<Migration> = arrayOf(
|
||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7,
|
||||
)
|
||||
}
|
||||
@@ -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<SyncOpDto>): SyncOpsResult =
|
||||
unwrap({ apiService.syncOps(SyncOpsRequest(operations)) })
|
||||
|
||||
/** Snapshot des permissions effectives (delta si `after` ms fourni). */
|
||||
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
|
||||
unwrap({ apiService.syncPermissions(after) })
|
||||
|
||||
private suspend fun <T> unwrap(
|
||||
call: suspend () -> Response<ApiEnvelope<T>>,
|
||||
skipUnauthorizedHandling: Boolean = false,
|
||||
|
||||
@@ -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<ApiEnvelope<LoginResponseDto>>
|
||||
|
||||
/** Outbox client→serveur : applique un batch séquentiel, idempotent par device. */
|
||||
@POST("sync/ops")
|
||||
suspend fun syncOps(
|
||||
@Body body: SyncOpsRequest,
|
||||
): Response<ApiEnvelope<SyncOpsResult>>
|
||||
|
||||
/** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */
|
||||
@GET("sync/permissions")
|
||||
suspend fun syncPermissions(
|
||||
@Query("after") after: Long? = null,
|
||||
): Response<ApiEnvelope<List<ResourcePermissionDto>>>
|
||||
}
|
||||
@@ -81,3 +81,44 @@ data class LoginResponseDto(
|
||||
@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<String, Any?>? = null,
|
||||
)
|
||||
|
||||
data class SyncOpsRequest(
|
||||
@Json(name = "operations") val operations: List<SyncOpDto>,
|
||||
)
|
||||
|
||||
/** 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,
|
||||
)
|
||||
+28
@@ -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,
|
||||
)
|
||||
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()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
@@ -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<List<FolderEntity>> = folderDao.observeRootFolders()
|
||||
@@ -103,7 +110,18 @@ class FolderRepository @Inject constructor(
|
||||
addedAt = existing?.addedAt ?: now,
|
||||
updatedAt = now,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -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<Map<String, Any?>> = 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<String, Any?> = 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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+12
-1
@@ -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)
|
||||
}
|
||||
|
||||
+155
@@ -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<Map<String, Any?>> = 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<OutboxSyncWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>(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) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user