sync oubox

This commit is contained in:
m
2026-09-13 09:58:19 +02:00
parent 201d6a03f2
commit bad04b3219
5 changed files with 104 additions and 5 deletions
@@ -48,6 +48,38 @@ interface FileDao {
@Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") @Query("UPDATE files SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId")
suspend fun markMissing(resourceId: String, updatedAt: Long) 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)") @Query("UPDATE files SET folder_resource_id = :folderId, updated_at = :updatedAt WHERE resource_id IN (:resourceIds)")
suspend fun moveToFolder(resourceIds: List<String>, folderId: String, updatedAt: Long) suspend fun moveToFolder(resourceIds: List<String>, folderId: String, updatedAt: Long)
@@ -52,6 +52,38 @@ interface FolderDao {
@Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") @Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId")
suspend fun markMissing(resourceId: String, updatedAt: Long) 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") @Query("DELETE FROM folders WHERE resource_id = :resourceId")
suspend fun remove(resourceId: String) suspend fun remove(resourceId: String)
} }
@@ -160,7 +160,7 @@ class FileRepository @Inject constructor(
return FileEntity( return FileEntity(
id = existing?.id ?: 0L, id = existing?.id ?: 0L,
resourceId = dto.id, resourceId = dto.id,
uri = null, uri = existing?.uri,
name = dto.name, name = dto.name,
folderResourceId = dto.folderId ?: folderResourceId, folderResourceId = dto.folderId ?: folderResourceId,
extension = extension, extension = extension,
@@ -170,7 +170,10 @@ class FileRepository @Inject constructor(
exists = 1, exists = 1,
lastModified = existing?.lastModified, lastModified = existing?.lastModified,
ownerId = existing?.ownerId, 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, processed = existing?.processed ?: false,
addedAt = existing?.addedAt ?: now, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
@@ -69,12 +69,15 @@ class FolderRepository @Inject constructor(
return FolderEntity( return FolderEntity(
id = existing?.id ?: 0L, id = existing?.id ?: 0L,
resourceId = dto.id, resourceId = dto.id,
uri = null, uri = existing?.uri,
name = dto.name, name = dto.name,
exists = null, exists = existing?.exists,
parentResourceId = dto.parentId, parentResourceId = dto.parentId,
ownerId = existing?.ownerId, 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, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
) )
@@ -14,6 +14,8 @@ import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.vaultdrop.mobile.auth.TokenProvider 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.dao.PendingOperationDao
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import com.vaultdrop.mobile.data.local.entity.PendingOperationType import com.vaultdrop.mobile.data.local.entity.PendingOperationType
@@ -50,6 +52,8 @@ class OutboxSyncWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
private val apiClient: ApiClient, private val apiClient: ApiClient,
private val pendingOperationDao: PendingOperationDao, private val pendingOperationDao: PendingOperationDao,
private val fileDao: FileDao,
private val folderDao: FolderDao,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
moshi: Moshi, moshi: Moshi,
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
@@ -62,6 +66,11 @@ class OutboxSyncWorker @AssistedInject constructor(
// Mode local : sans compte connecté, rien à pousser. // Mode local : sans compte connecté, rien à pousser.
if (tokenProvider.current == null) return Result.success() 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) { while (true) {
val pending = pendingOperationDao.selectPending(BATCH_SIZE) val pending = pendingOperationDao.selectPending(BATCH_SIZE)
if (pending.isEmpty()) { if (pending.isEmpty()) {
@@ -95,6 +104,7 @@ class OutboxSyncWorker @AssistedInject constructor(
// Ops appliquées par le serveur (indices < applied). // Ops appliquées par le serveur (indices < applied).
for (i in 0 until result.applied) { for (i in 0 until result.applied) {
pendingOperationDao.markSynced(pending[i].id, now) pendingOperationDao.markSynced(pending[i].id, now)
promotePlacement(pending[i])
Timber.d("outbox synced %s", pending[i].operationId) 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 { private fun PendingOperationEntity.toSyncOpDto(): SyncOpDto {
val payloadMap = runCatching { payloadAdapter.fromJson(payload) }.getOrNull() val payloadMap = runCatching { payloadAdapter.fromJson(payload) }.getOrNull()
return SyncOpDto( return SyncOpDto(