From bad04b32192c44eb931da1b381854a83c21b1250 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 13 Sep 2026 09:58:19 +0200 Subject: [PATCH] sync oubox --- .../mobile/data/local/dao/FileDao.kt | 32 +++++++++++++++++++ .../mobile/data/local/dao/FolderDao.kt | 32 +++++++++++++++++++ .../mobile/data/repository/FileRepository.kt | 7 ++-- .../data/repository/FolderRepository.kt | 9 ++++-- .../mobile/features/sync/OutboxSyncWorker.kt | 29 +++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index e4b1cda..d6be059 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -48,6 +48,38 @@ interface FileDao { @Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") suspend fun markMissing(resourceId: String, updatedAt: Long) + /** + * Promote le placement après confirmation serveur (`POST /sync/ops` appliqué) : + * `local-cloud` si une copie physique existe, sinon `cloud`. Idempotent. + */ + @Query(""" + UPDATE files + SET sync_status = CASE WHEN uri IS NOT NULL THEN 'local-cloud' ELSE 'cloud' END, + updated_at = :now + WHERE resource_id = :resourceId + """) + suspend fun promoteSyncStatus(resourceId: String, now: Long) + + /** + * Backfill : promu toutes les lignes restées `local` dont une op outbox + * `create_resource`/`move_resource` a déjà été `synced` (poussées avant ce + * mécanisme). Rattrape le pas pour les données préexistantes. + */ + @Query(""" + UPDATE files + SET sync_status = CASE WHEN uri IS NOT NULL THEN 'local-cloud' ELSE 'cloud' END, + updated_at = :now + WHERE sync_status = 'local' + AND EXISTS ( + SELECT 1 FROM pending_operations + WHERE pending_operations.resource_id = files.resource_id + AND pending_operations.resource_type = 'file' + AND pending_operations.status = 'synced' + AND pending_operations.operation IN ('create_resource', 'move_resource') + ) + """) + suspend fun backfillSyncedStatus(now: Long) + @Query("UPDATE files SET folder_resource_id = :folderId, updated_at = :updatedAt WHERE resource_id IN (:resourceIds)") suspend fun moveToFolder(resourceIds: List, folderId: String, updatedAt: Long) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt index a65522b..64d4ba6 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -52,6 +52,38 @@ interface FolderDao { @Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") suspend fun markMissing(resourceId: String, updatedAt: Long) + /** + * Promote le placement après confirmation serveur (`POST /sync/ops` appliqué) : + * `local-cloud` si une copie physique existe, sinon `cloud`. Idempotent. + */ + @Query(""" + UPDATE folders + SET sync_status = CASE WHEN uri IS NOT NULL THEN 'local-cloud' ELSE 'cloud' END, + updated_at = :now + WHERE resource_id = :resourceId + """) + suspend fun promoteSyncStatus(resourceId: String, now: Long) + + /** + * Backfill : promu toutes les lignes restées `local` dont une op outbox + * `create_resource`/`move_resource` a déjà été `synced` (poussées avant ce + * mécanisme). Rattrape le pas pour les données préexistantes. + */ + @Query(""" + UPDATE folders + SET sync_status = CASE WHEN uri IS NOT NULL THEN 'local-cloud' ELSE 'cloud' END, + updated_at = :now + WHERE sync_status = 'local' + AND EXISTS ( + SELECT 1 FROM pending_operations + WHERE pending_operations.resource_id = folders.resource_id + AND pending_operations.resource_type = 'folder' + AND pending_operations.status = 'synced' + AND pending_operations.operation IN ('create_resource', 'move_resource') + ) + """) + suspend fun backfillSyncedStatus(now: Long) + @Query("DELETE FROM folders WHERE resource_id = :resourceId") suspend fun remove(resourceId: String) } \ 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 2c5d4f4..ca78539 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 @@ -160,7 +160,7 @@ class FileRepository @Inject constructor( return FileEntity( id = existing?.id ?: 0L, resourceId = dto.id, - uri = null, + uri = existing?.uri, name = dto.name, folderResourceId = dto.folderId ?: folderResourceId, extension = extension, @@ -170,7 +170,10 @@ class FileRepository @Inject constructor( exists = 1, lastModified = existing?.lastModified, ownerId = existing?.ownerId, - syncStatus = existing?.syncStatus ?: FileStatus.CLOUD, + // Le snapshot serveur confirme la présence cloud : si une copie + // physique existe aussi, placement local-cloud (jamais d'écrasement + // de l'uri / de régression du placement). + syncStatus = if (existing?.uri != null) FileStatus.LOCAL_CLOUD else FileStatus.CLOUD, processed = existing?.processed ?: false, addedAt = existing?.addedAt ?: now, updatedAt = now, 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 1594196..7dc57d0 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 @@ -69,12 +69,15 @@ class FolderRepository @Inject constructor( return FolderEntity( id = existing?.id ?: 0L, resourceId = dto.id, - uri = null, + uri = existing?.uri, name = dto.name, - exists = null, + exists = existing?.exists, parentResourceId = dto.parentId, ownerId = existing?.ownerId, - syncStatus = existing?.syncStatus ?: FolderStatus.CLOUD, + // Le snapshot serveur confirme la présence cloud : si une copie + // physique existe aussi, placement local-cloud (jamais d'écrasement + // de l'uri / de régression du placement). + syncStatus = if (existing?.uri != null) FolderStatus.LOCAL_CLOUD else FolderStatus.CLOUD, addedAt = existing?.addedAt ?: now, updatedAt = now, ) 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 index fa693fe..98d872a 100644 --- 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 @@ -14,6 +14,8 @@ 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.FileDao +import com.vaultdrop.mobile.data.local.dao.FolderDao import com.vaultdrop.mobile.data.local.dao.PendingOperationDao import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.local.entity.PendingOperationType @@ -50,6 +52,8 @@ class OutboxSyncWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters, private val apiClient: ApiClient, private val pendingOperationDao: PendingOperationDao, + private val fileDao: FileDao, + private val folderDao: FolderDao, private val tokenProvider: TokenProvider, moshi: Moshi, ) : CoroutineWorker(appContext, workerParams) { @@ -62,6 +66,11 @@ class OutboxSyncWorker @AssistedInject constructor( // Mode local : sans compte connecté, rien à pousser. if (tokenProvider.current == null) return Result.success() + // Backfill : promeut les ressources déjà confirmées par le serveur mais + // dont le placement était resté `local` (poussées avant ce mécanisme). + fileDao.backfillSyncedStatus(System.currentTimeMillis()) + folderDao.backfillSyncedStatus(System.currentTimeMillis()) + while (true) { val pending = pendingOperationDao.selectPending(BATCH_SIZE) if (pending.isEmpty()) { @@ -95,6 +104,7 @@ class OutboxSyncWorker @AssistedInject constructor( // Ops appliquées par le serveur (indices < applied). for (i in 0 until result.applied) { pendingOperationDao.markSynced(pending[i].id, now) + promotePlacement(pending[i]) Timber.d("outbox synced %s", pending[i].operationId) } @@ -117,6 +127,25 @@ class OutboxSyncWorker @AssistedInject constructor( } } + /** + * Après confirmation serveur, promeut le placement de la ressource touchée : + * `create_resource`/`move_resource` appliqués → `local-cloud` si copie + * physique présente, `cloud` sinon. Rappelle le statut d'une ressource déjà + * cloud (idempotent), `delete_resource` n'en modifie pas. + */ + private suspend fun promotePlacement(op: PendingOperationEntity) { + val resourceId = op.resourceId ?: return + when (op.operation) { + PendingOperationType.CREATE_RESOURCE, + PendingOperationType.MOVE_RESOURCE -> { + when (op.resourceType) { + "file" -> fileDao.promoteSyncStatus(resourceId, System.currentTimeMillis()) + "folder" -> folderDao.promoteSyncStatus(resourceId, System.currentTimeMillis()) + } + } + } + } + private fun PendingOperationEntity.toSyncOpDto(): SyncOpDto { val payloadMap = runCatching { payloadAdapter.fromJson(payload) }.getOrNull() return SyncOpDto(