merge feature

This commit is contained in:
m
2026-09-14 15:46:06 +02:00
parent 4caa723853
commit 6a991f0e21
4 changed files with 540 additions and 0 deletions
@@ -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<FileEntity>,
val folders: List<FolderEntity>,
)
suspend fun deleteFolders(
folders: List<FolderEntity>,
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<FolderEntity>()
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
}
}
@@ -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<FolderEntity>, 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,
)
}
}
@@ -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<Context>()
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<ApiEnvelope<List<FolderDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
override suspend fun listFiles(
folderId: String?,
page: Int?,
pageSize: Int?,
): Response<ApiEnvelope<List<FileDto>>> = Response.success(ApiEnvelope(data = emptyList()))
override suspend fun registerDevice(
body: DeviceRegistrationDto,
): Response<ApiEnvelope<DeviceRegistrationDto>> = Response.success(ApiEnvelope(data = body))
override suspend fun login(
body: LoginRequestDto,
): Response<ApiEnvelope<LoginResponseDto>> = Response.success(ApiEnvelope())
override suspend fun syncOps(
body: SyncOpsRequest,
): Response<ApiEnvelope<SyncOpsResult>> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0)))
override suspend fun resolveUser(
username: String,
): Response<ApiEnvelope<ResolvedUserDto>> = Response.success(ApiEnvelope())
override suspend fun syncPermissions(
after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
}