From 6a991f0e215cee2b6562b70764f68a3bb203ca5b Mon Sep 17 00:00:00 2001 From: m Date: Mon, 14 Sep 2026 15:46:06 +0200 Subject: [PATCH] merge feature --- README.md | 31 +++ .../mobile/features/saf/FolderDeleter.kt | 195 ++++++++++++++++ .../mobile/features/saf/FolderMerger.kt | 105 +++++++++ .../data/repository/FolderMergeDataTest.kt | 209 ++++++++++++++++++ 4 files changed, 540 insertions(+) create mode 100644 README.md create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderDeleter.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderMerger.kt create mode 100644 mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt diff --git a/README.md b/README.md new file mode 100644 index 0000000..493d9f0 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# VaultDrop + +VaultDrop est un coffre-fort de documents personnel, local-first. Le client Android镜映 le SAF dans une base Room et pousse les métadonnées de ses mutations via un outbox transactionnel vers le serveur Go/PostgreSQL — pas d'upload physique, pas de cloud-dépendance. + +Disponible sur [thirdshop.fr](https://thirdshop.fr/en/applications/vault/) + +## Setup + +```bash +# Backend (Go + PostgreSQL) +mise up_backend + +# Frontend (Android, build debug APK) +mise up_mobile # émulateur par défaut +mise up_mobile device # sur un appareil connecté + +# Tests +cd backend && go test ./... +cd mobile-kotlin && ./gradlew :app:testDebugUnitTest +``` + +## Où regarder + +| Zone | Fichiers clés | +|------|---------------| +| API contract | `docs/api-v1.md` — contrat HTTP autoritatif | +| Backend entry | `backend/cmd/server/main.go` — wiring gin + routes | +| Sync outbox | `mobile-kotlin/.../features/sync/OutboxSyncWorker.kt` — worker WorkManager → `POST /sync/ops` | +| SAF scan | `mobile-kotlin/.../features/sync/DeviceSync.kt` — two-pass walk SAF ↔ Room | +| Room DB | `mobile-kotlin/.../data/local/AppDatabase.kt` — version 7, migrations, DAO | +| API Retrofit | `mobile-kotlin/.../data/remote/ApiService.kt` — interface Retrofit (contrat client) | diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderDeleter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderDeleter.kt new file mode 100644 index 0000000..a4aa888 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderDeleter.kt @@ -0,0 +1,195 @@ +package com.vaultdrop.mobile.features.saf + +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.local.entity.FolderEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.OutboxRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Suppression de dossiers en mode multi-sélection. + * + * Un dossier SAF ne peut être supprimé physiquement que vide : on supprime donc + * récursivement son contenu (fichiers puis sous-dossiers, du plus profond au + * plus proche) avant le dossier lui-même. Room sert de source de vérité pour + * l'énumération du contenu (issue du walk SAF). + * + * Trois modes (partagés avec `FileDeleter`) : + * - **LOCALLY** : suppression physique SAF de l'arbre + marquage `exists = 0` + * Room, sans outbox (le serveur n'est pas affecté). + * - **IN_CLOUD** : enqueue `delete_resource` pour les dossiers uniquement. + * - **FULL** : les deux — suppression physique + outbox, transaction atomique. + */ +@Singleton +class FolderDeleter @Inject constructor( + @ApplicationContext private val context: Context, + private val folderRepository: FolderRepository, + private val fileRepository: FileRepository, + private val outboxRepository: OutboxRepository, + private val appDatabase: AppDatabase, +) { + + private val resolver: ContentResolver get() = context.contentResolver + + /** Descendants collectés d'un dossier — fichiers à tout niveau + sous-dossiers. */ + private data class FolderTree( + val files: List, + val folders: List, + ) + + suspend fun deleteFolders( + folders: List, + mode: FileDeleter.DeleteMode, + ): FileDeleter.DeleteReport { + var succeeded = 0 + var failed = 0 + withContext(Dispatchers.IO) { + for (folder in folders) { + val ok = when (mode) { + FileDeleter.DeleteMode.LOCALLY -> deleteLocally(folder) + FileDeleter.DeleteMode.IN_CLOUD -> deleteInCloud(folder) + FileDeleter.DeleteMode.FULL -> deleteFull(folder) + } + if (ok) succeeded++ else failed++ + } + } + return FileDeleter.DeleteReport(succeeded, failed) + } + + /** Suppression physique locale uniquement (SAF + Room), outbox absente. */ + private suspend fun deleteLocally(folder: FolderEntity): Boolean { + val uri = folder.uri ?: return false + val tree = collectDescendants(folder.resourceId) + if (deleteTreePhysically(folder, tree)) { + val now = System.currentTimeMillis() + tree.files.forEach { fileRepository.markMissing(it.resourceId, now) } + tree.folders.forEach { folderRepository.markMissing(it.resourceId, now) } + folderRepository.markMissing(folder.resourceId, now) + Timber.d("deleted folder locally %s", uri) + return true + } + Timber.w("local delete returned false for %s", uri) + return false + } + + /** Suppression cloud uniquement (outbox `delete_resource`), physique intact. */ + private suspend fun deleteInCloud(folder: FolderEntity): Boolean { + var ok = false + runCatching { + if (outboxRepository.hasCreateOperationAnyType(folder.resourceId)) { + outboxRepository.enqueueDeleteResource(folder.resourceId, "folder") + } + ok = true + Timber.d("enqueued cloud delete for folder %s", folder.resourceId) + }.onFailure { e -> + Timber.w(e, "enqueueDeleteResource failed for folder %s", folder.resourceId) + } + return ok + } + + /** Suppression complète : physique + cloud. Transaction atomique Room. */ + private suspend fun deleteFull(folder: FolderEntity): Boolean { + val uri = folder.uri + if (uri != null) { + val tree = collectDescendants(folder.resourceId) + if (deleteTreePhysically(folder, tree)) { + val now = System.currentTimeMillis() + appDatabase.withTransaction { + treeMarkMissing(folder, tree, now) + } + Timber.d("deleted folder full %s", uri) + return true + } + Timber.w("deleteDocument returned false for %s", uri) + return false + } + // Cloud-only : outbox uniquement. + return deleteInCloud(folder) + } + + /** Marque missing + outbox `delete_resource` pour toute la descendance, atomique. */ + private suspend fun treeMarkMissing(folder: FolderEntity, tree: FolderTree, now: Long) { + for (file in tree.files) { + fileRepository.markMissing(file.resourceId, now) + if (outboxRepository.hasCreateOperation(file.resourceId)) { + outboxRepository.enqueueDeleteResource(file.resourceId, "file") + } + } + for (desc in tree.folders) { + folderRepository.markMissing(desc.resourceId, now) + if (outboxRepository.hasCreateOperationAnyType(desc.resourceId)) { + outboxRepository.enqueueDeleteResource(desc.resourceId, "folder") + } + } + folderRepository.markMissing(folder.resourceId, now) + if (outboxRepository.hasCreateOperationAnyType(folder.resourceId)) { + outboxRepository.enqueueDeleteResource(folder.resourceId, "folder") + } + } + + /** Énumère le contenu d'un dossier (fichiers + sous-dossiers, tout niveau) via Room. */ + private suspend fun collectDescendants(folderResourceId: String): FolderTree { + val files = fileRepository.getFilesInFolder(folderResourceId).toMutableList() + val folders = mutableListOf() + val children = folderRepository.getChildren(folderResourceId) + for (child in children) { + val sub = collectDescendants(child.resourceId) + files += sub.files + folders += child + folders += sub.folders + } + return FolderTree(files = files, folders = folders) + } + + /** + * Suppression SAF physique d'un arbre : fichiers d'abord, puis sous-dossiers + * du plus profond au plus proche, puis le dossier racine (deleteDocument + * exige un conteneur vide). Retourne `true` si tout l'arbre est supprimé. + */ + private suspend fun deleteTreePhysically(folder: FolderEntity, tree: FolderTree): Boolean { + for (file in tree.files) { + if (!deleteDocument(file.uri)) return false + } + val orderedDirs = tree.folders + .map { it to depthOf(it.resourceId, folder.resourceId) } + .sortedByDescending { it.second } + .map { it.first } + for (dir in orderedDirs) { + if (!deleteDocument(dir.uri)) return false + } + return deleteDocument(folder.uri) + } + + /** Appelle `deleteDocument` SAF ; sans uri = rien à faire (= succès). */ + private suspend fun deleteDocument(uri: String?): Boolean { + if (uri == null) return true + return runCatching { + DocumentsContract.deleteDocument(resolver, Uri.parse(uri)) + }.onFailure { e -> + Timber.w(e, "deleteDocument failed for %s", uri) + }.getOrDefault(false) + } + + /** Profondeur d'un descendant par rapport à la racine de l'arbre (itère Room). */ + private suspend fun depthOf(resourceId: String, rootResourceId: String): Int { + var depth = 0 + var current = folderRepository.getFolder(resourceId) + while (current != null && current.parentResourceId != null && current.parentResourceId != rootResourceId) { + depth++ + current = folderRepository.getFolder(current.parentResourceId) + } + return depth + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderMerger.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderMerger.kt new file mode 100644 index 0000000..e9e5d29 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FolderMerger.kt @@ -0,0 +1,105 @@ +package com.vaultdrop.mobile.features.saf + +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.SaveFolderInput +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Fusion de dossiers en mode multi-sélection. + * + * 1. Crée un nouveau dossier nommé `newName` au même niveau que les dossiers + * sélectionnés (même parent). + * 2. Déplace tout le contenu de chaque dossier source (fichiers + sous-dossiers) + * vers le dossier fusionné — relocation SAF quand les deux côtés sont + * physiques, repli métadonnée seule sinon (`move_resource` outbox quand même). + * 3. Supprime les dossiers sources désormais vides (physique + outbox). + * + * L'ordre outbox est garanti par l'insertion séquentielle : `create_resource` + * du dossier fusionné avant les `move_resource`, avant les `delete_resource`. + */ +@Singleton +class FolderMerger @Inject constructor( + private val folderRepository: FolderRepository, + private val fileRepository: FileRepository, + private val fileMover: FileMover, + private val folderDeleter: FolderDeleter, + private val safFolderCreator: SafFolderCreator, +) { + + data class MergeReport( + val movedFiles: Int, + val deletedFolders: Int, + val failed: Int, + ) + + /** + * Fusionne `folders` (frères, même parcours d'explorateur) sous un nouveau + * dossier `newName`. Retourne un rapport d'exécution. + */ + suspend fun mergeSelectedFolders(folders: List, newName: String): MergeReport { + require(folders.size >= 2) { "merge requires at least 2 folders" } + + // 1. Dossier fusionné au même niveau que les sources. + val parentId = folders.first().parentResourceId + val merged = createMergedFolder(parentId, newName) + + // 2. Déplacement du contenu de chaque dossier source. + var movedFiles = 0 + var stepFailures = 0 + withContext(Dispatchers.IO) { + for (src in folders) { + val ok = runCatching { + val files = fileRepository.getFilesInFolder(src.resourceId) + if (files.isNotEmpty()) { + fileMover.moveFiles(files.map { it.resourceId }, merged.resourceId) + movedFiles += files.size + } + val subFolders = folderRepository.getChildren(src.resourceId) + if (subFolders.isNotEmpty()) { + folderRepository.reparentSubFolders(subFolders.map { it.resourceId }, merged.resourceId) + } + }.isSuccess + if (!ok) stepFailures++ + } + } + + // 3. Suppression des dossiers sources (désormais sans contenu). + var deletedFolders = 0 + var failed = 0 + withContext(Dispatchers.IO) { + for (src in folders) { + val report = folderDeleter.deleteFolders(listOf(src), FileDeleter.DeleteMode.FULL) + if (report.failed == 0) deletedFolders++ else failed++ + } + } + failed += stepFailures + Timber.d("merge '%s': %d fichiers déplacés, %d dossiers supprimés, %d échecs", + newName, movedFiles, deletedFolders, failed) + return MergeReport(movedFiles = movedFiles, deletedFolders = deletedFolders, failed = failed) + } + + /** + * Crée (physique si le parent l'est, cloud-only sinon) et enregistre en Room + * le dossier fusionné. Retourne l'entité Room. La sauvegarde enqueue le + * `create_resource` dans la même transaction. + */ + private suspend fun createMergedFolder(parentId: String?, newName: String): FolderEntity { + val parent = parentId?.let { folderRepository.getFolder(it) } + val createdUri = parent?.uri?.let { safFolderCreator.createFolder(it, newName) }?.toString() + return folderRepository.saveFolder( + input = SaveFolderInput( + uri = createdUri, + name = newName, + exists = true, + createdInApp = true, + ), + parentResourceId = parentId, + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt new file mode 100644 index 0000000..4774525 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt @@ -0,0 +1,209 @@ +package com.vaultdrop.mobile.data.repository + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationType +import com.vaultdrop.mobile.data.local.entity.PendingOpStatus +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiService +import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope +import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto +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.ResolvedUserDto +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 com.vaultdrop.mobile.domain.DeviceIdentity +import com.vaultdrop.mobile.domain.GenerateId +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.Response + +/** + * Garde-fous data-layer de la fusion de dossiers : le re-parentage repositionne + * les sous-dossiers et journalise un `move_resource` par dossier (ordre outbox), + * et le garde `delete_resource` reconnaît les dossiers poussés (`resource_type = + * 'folder'`) via `hasCreateOperationAnyType`. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class FolderMergeDataTest { + + private lateinit var db: AppDatabase + private lateinit var folderDao: FolderDao + private lateinit var opsDao: PendingOperationDao + private lateinit var folderRepository: FolderRepository + private lateinit var outboxRepository: OutboxRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + folderDao = db.folderDao() + opsDao = db.pendingOperationDao() + + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + val generateId = GenerateId() + val deviceIdentity = DeviceIdentity(db.userPreferenceDao(), generateId) + outboxRepository = OutboxRepository(opsDao, generateId, moshi) + folderRepository = FolderRepository( + folderDao = folderDao, + apiClient = ApiClient(FolderMergeApiService(), moshi), + generateId = generateId, + deviceIdentity = deviceIdentity, + appDatabase = db, + outboxRepository = outboxRepository, + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun reparent_repositionne_les_sous_dossiers_et_enqueue_move_resource_par_dossier() = runTest { + folderDao.upsert(folder(ROOT, parent = null)) + folderDao.upsert(folder(CHILD_A, parent = ROOT)) + folderDao.upsert(folder(CHILD_B, parent = ROOT)) + + folderRepository.reparentSubFolders(listOf(CHILD_A, CHILD_B), MERGED) + + assertEquals("parent_A repositionné", MERGED, folderDao.getByResourceId(CHILD_A)!!.parentResourceId) + assertEquals("parent_B repositionné", MERGED, folderDao.getByResourceId(CHILD_B)!!.parentResourceId) + + val ops = opsDao.selectPending(10) + assertEquals("une op move par dossier (ordre outbox)", 2, ops.size) + assertEquals(PendingOperationType.MOVE_RESOURCE, ops[0].operation) + assertEquals("folder", ops[0].resourceType) + assertEquals(CHILD_A, ops[0].resourceId) + assertEquals(ROOT_WITH_MERGED, ops[0].payload) + assertEquals(PendingOperationType.MOVE_RESOURCE, ops[1].operation) + assertEquals(CHILD_B, ops[1].resourceId) + assertEquals(PendingOpStatus.PENDING, ops[1].status) + } + + @Test + fun reparent_expose_les_enfants_au_nouveau_parent() = runTest { + folderDao.upsert(folder(ROOT, parent = null)) + folderDao.upsert(folder(CHILD_A, parent = ROOT)) + folderDao.upsert(folder(CHILD_B, parent = ROOT)) + // L'ancien parent n'a plus d'enfants visibles. + assertEquals(2, folderRepository.getChildren(ROOT).size) + + folderRepository.reparentSubFolders(listOf(CHILD_A, CHILD_B), MERGED) + + assertEquals(listOf(CHILD_A, CHILD_B), folderRepository.getChildren(MERGED).map { it.resourceId }) + assertEquals("l'ancien parent est vide", 0, folderRepository.getChildren(ROOT).size) + } + + @Test + fun reparent_vide_est_un_noop() = runTest { + folderRepository.reparentSubFolders(emptyList(), MERGED) + assertEquals(0, opsDao.selectPending(10).size) + } + + @Test + fun hasCreateOperationAnyType_reconnait_les_dossiers_pousses() = runTest { + opsDao.insert( + PendingOperationEntity( + operationId = "1".repeat(32), + resourceId = FOLDER_ID, + resourceType = "folder", + operation = PendingOperationType.CREATE_RESOURCE, + payload = "{}", + status = PendingOpStatus.PENDING, + createdAt = NOW, + updatedAt = NOW, + ), + ) + + assertFalse( + "le gate « processed » (fichiers) reste scindé sur resource_type = 'file'", + outboxRepository.hasCreateOperation(FOLDER_ID), + ) + assertTrue( + "un delete_resource peut être émis pour un dossier déjà poussé", + outboxRepository.hasCreateOperationAnyType(FOLDER_ID), + ) + } + + // --- fixtures ------------------------------------------------------------ + + private fun folder(resourceId: String, parent: String?) = FolderEntity( + resourceId = resourceId, + uri = null, + name = resourceId, + exists = 1, + parentResourceId = parent, + ownerId = OWNER, + syncStatus = "cloud", + addedAt = NOW, + updatedAt = NOW, + ) + + private companion object { + const val NOW = 1_700_000_000_000L + const val ROOT = "11111111111111111111111111111111" + const val CHILD_A = "22222222222222222222222222222222" + const val CHILD_B = "33333333333333333333333333333333" + const val MERGED = "44444444444444444444444444444444" + const val OWNER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val FOLDER_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + val ROOT_WITH_MERGED = """{"toFolderResourceId":"$MERGED"}""" + } +} + +/** Fake `ApiService` — aucun endpoint n'est consommé par `reparentSubFolders`. */ +private class FolderMergeApiService : ApiService { + override suspend fun listFolders(): Response>> = + Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun listFiles( + folderId: String?, + page: Int?, + pageSize: Int?, + ): Response>> = Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun registerDevice( + body: DeviceRegistrationDto, + ): Response> = Response.success(ApiEnvelope(data = body)) + + override suspend fun login( + body: LoginRequestDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncOps( + body: SyncOpsRequest, + ): Response> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0))) + + override suspend fun resolveUser( + username: String, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncPermissions( + after: Long?, + ): Response>> = + Response.success(ApiEnvelope(data = emptyList())) +} \ No newline at end of file