diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt index 0a86de7..7be6b89 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt @@ -42,6 +42,20 @@ interface PendingOperationDao { @Query("DELETE FROM pending_operations WHERE status = 'synced' AND updated_at < :cutoff") suspend fun purgeSynced(cutoff: Long) + /** + * Une op `create_resource` (pending ou synced) existe-t-elle déjà pour ce + * fichier ? Le gate « processed » enqueue le create au moment du review — + * ce garde-fou évite de le re-enqueue (re-keep, purge > 7j, etc.). + */ + @Query(""" + SELECT COUNT(*) FROM pending_operations + WHERE resource_id = :resourceId + AND operation = 'create_resource' + AND resource_type = 'file' + AND status IN ('pending', 'synced') + """) + suspend fun countCreateOperations(resourceId: String): Int + @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'") suspend fun countPending(): Int } \ 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 bd10280..0b0cb50 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 @@ -60,12 +60,55 @@ class FileRepository @Inject constructor( /** Snapshot de la file de review, chargé à l'entrée dans le mode traitement. */ suspend fun getUnprocessed(): List = fileDao.getUnprocessed() - /** Marque un fichier comme traité (gardé) — local au device, jamais poussé. */ - suspend fun markProcessed(resourceId: String) = - fileDao.markProcessed(resourceId, System.currentTimeMillis()) + /** + * Marque un fichier comme traité (gardé) — déclenche aussi le push du + * `create_resource` vers l'outbox, dans la même transaction (gate + * « processed » : un fichier n'est synchronisé que lorsqu'il est gardé). + * Idempotent : un `create_resource` déjà enqueue (ou synced) interdit un + * doublon. + */ + suspend fun markProcessed(resourceId: String) { + val now = System.currentTimeMillis() + appDatabase.withTransaction { + val file = fileDao.getByResourceId(resourceId) ?: return@withTransaction + fileDao.markProcessed(resourceId, now) + if (file.exists == 1 && file.uri != null && !outboxRepository.hasCreateOperation(resourceId)) { + outboxRepository.enqueueCreateResource( + resourceId = file.resourceId, + resourceType = "file", + name = file.name, + parentResourceId = file.folderResourceId, + mimeType = file.mimeType, + extension = file.extension, + ) + } + } + } - /** Marque tous les fichiers locaux restants comme traités. */ - suspend fun markAllProcessed() = fileDao.markAllProcessed(System.currentTimeMillis()) + /** + * Échappatoire : marque tous les fichiers locaux restants comme traités ET + * synchronisés (un `create_resource` par fichier, dans la même transaction). + */ + suspend fun markAllProcessed() { + val now = System.currentTimeMillis() + appDatabase.withTransaction { + val files = fileDao.getUnprocessed() + if (files.isEmpty()) return@withTransaction + for (file in files) { + fileDao.markProcessed(file.resourceId, now) + if (!outboxRepository.hasCreateOperation(file.resourceId)) { + outboxRepository.enqueueCreateResource( + resourceId = file.resourceId, + resourceType = "file", + name = file.name, + parentResourceId = file.folderResourceId, + mimeType = file.mimeType, + extension = file.extension, + ) + } + } + } + } suspend fun getFile(resourceId: String): FileEntity? = fileDao.getByResourceId(resourceId) @@ -109,8 +152,11 @@ class FileRepository @Inject constructor( ) appDatabase.withTransaction { fileDao.upsert(entity) - if (existing == null) { - // Nouveau fichier physique → le pousser vers le serveur (métadonnées). + if (existing == null && entity.processed) { + // Nouveau fichier physique déjà « traité » (ex. scan export) : + // poussé immédiatement. Un fichier SAF tout juste découvert + // reste `processed = false` → local-only, il n'est poussé qu'au + // « garder » de la review (`markProcessed`). outboxRepository.enqueueCreateResource( resourceId = entity.resourceId, resourceType = "file", diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt index dd8d5d1..1c97d1f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt @@ -105,4 +105,12 @@ class OutboxRepository @Inject constructor( /** Nombre d'ops en attente de push (stats UI optionnelles). */ suspend fun countPending(): Int = pendingOperationDao.countPending() + + /** + * Un `create_resource` (pending ou synced) existe-t-il déjà pour ce fichier ? + * Utilisé par le gate « processed » : seuls les fichiers traités déclenchent + * le push, et jamais deux fois. + */ + suspend fun hasCreateOperation(resourceId: String): Boolean = + pendingOperationDao.countCreateOperations(resourceId) > 0 } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileDeleter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileDeleter.kt new file mode 100644 index 0000000..5f3ded6 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/FileDeleter.kt @@ -0,0 +1,129 @@ +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.FileStatus +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 +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Suppression de fichiers en mode multi-sélection. + * + * Trois modes : + * - **LOCALLY** : supprime le fichier physique SAF + marque `exists = 0` en Room, + * sans journaliser dans l\'outbox (le serveur n\'est pas affecté). + * - **IN_CLOUD** : enqueue `delete_resource` dans l\'outbox uniquement, sans + * toucher au fichier local. + * - **FULL** : les deux — suppression physique + outbox `delete_resource`. + */ +@Singleton +class FileDeleter @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 + + enum class DeleteMode { LOCALLY, IN_CLOUD, FULL } + + data class DeleteReport(val succeeded: Int, val failed: Int) + + suspend fun deleteFiles(files: List, mode: DeleteMode): DeleteReport { + var succeeded = 0 + var failed = 0 + withContext(Dispatchers.IO) { + for (file in files) { + val ok = when (mode) { + DeleteMode.LOCALLY -> deleteLocally(file) + DeleteMode.IN_CLOUD -> deleteInCloud(file) + DeleteMode.FULL -> deleteFull(file) + } + if (ok) succeeded++ else failed++ + } + } + return DeleteReport(succeeded, failed) + } + + /** + * Suppression physique locale uniquement (SAF + Room). + * Pas d\'outbox : le serveur n\'est pas notifié. + */ + private suspend fun deleteLocally(file: FileEntity): Boolean { + val uri = file.uri ?: return false + return runCatching { + DocumentsContract.deleteDocument(resolver, Uri.parse(uri)) + }.onSuccess { deleted -> + if (deleted) { + fileRepository.markMissing(file.resourceId, System.currentTimeMillis()) + Timber.d("deleted locally %s", uri) + } else { + Timber.w("deleteDocument returned false for %s", uri) + } + }.onFailure { e -> + Timber.w(e, "deleteDocument failed for %s", uri) + }.getOrDefault(false) + } + + /** + * Suppression cloud uniquement (outbox `delete_resource`). + * Le fichier local n\'est pas touché. N\'a de sens que si la ressource a + * déjà été poussée (gate « processed » : un fichier jamais envoyé est no-op). + */ + private suspend fun deleteInCloud(file: FileEntity): Boolean { + var ok = false + runCatching { + if (outboxRepository.hasCreateOperation(file.resourceId)) { + outboxRepository.enqueueDeleteResource(file.resourceId, "file") + } + ok = true + Timber.d("enqueued cloud delete for %s", file.resourceId) + }.onFailure { e -> + Timber.w(e, "enqueueDeleteResource failed for %s", file.resourceId) + } + return ok + } + + /** + * Suppression complète : physique + cloud. + * Transaction atomique : SAF delete + markMissing + outbox. + */ + private suspend fun deleteFull(file: FileEntity): Boolean { + val uri = file.uri + return if (uri != null) { + runCatching { + DocumentsContract.deleteDocument(resolver, Uri.parse(uri)) + }.onSuccess { deleted -> + if (deleted) { + val now = System.currentTimeMillis() + appDatabase.withTransaction { + fileRepository.markMissing(file.resourceId, now) + if (outboxRepository.hasCreateOperation(file.resourceId)) { + outboxRepository.enqueueDeleteResource(file.resourceId, "file") + } + } + Timber.d("deleted full %s", uri) + } else { + Timber.w("deleteDocument returned false for %s", uri) + } + }.onFailure { e -> + Timber.w(e, "deleteDocument failed for %s", uri) + }.getOrDefault(false) + } else { + // Cloud-only : outbox uniquement. + deleteInCloud(file) + } + } +} diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt index 2e03627..0bc2774 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt @@ -49,7 +49,13 @@ class SafFileDeleter @Inject constructor( val now = System.currentTimeMillis() appDatabase.withTransaction { fileRepository.markMissing(file.resourceId, now) - outboxRepository.enqueueDeleteResource(file.resourceId, "file") + // Gate « processed » : si le fichier n'a jamais été poussé + // (create_resource absent), le serveur n'en sait rien — + // un delete_resource serait du bruit. On ne le journalise + // que pour les ressources déjà synchronisées. + if (outboxRepository.hasCreateOperation(file.resourceId)) { + outboxRepository.enqueueDeleteResource(file.resourceId, "file") + } } Timber.d("deleted %s", uri) } else { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/DeleteSelectionBar.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/DeleteSelectionBar.kt new file mode 100644 index 0000000..2137811 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/DeleteSelectionBar.kt @@ -0,0 +1,265 @@ +package com.vaultdrop.mobile.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus +import com.vaultdrop.mobile.features.saf.FileDeleter + +/** Résultat de l\'analyse de compatibilité entre les fichiers et le mode choisi. */ +data class DeleteReview( + val mode: FileDeleter.DeleteMode, + val total: Int, + val incompatibleCount: Int, +) { + val hasWarning: Boolean get() = incompatibleCount > 0 +} + +/** Analyse les fichiers sélectionnés par rapport au mode de suppression choisi. */ +fun reviewDelete(files: List, mode: FileDeleter.DeleteMode): DeleteReview { + val incompatible = when (mode) { + FileDeleter.DeleteMode.IN_CLOUD -> + files.count { it.syncStatus == FileStatus.LOCAL } + FileDeleter.DeleteMode.LOCALLY -> + files.count { it.syncStatus == FileStatus.CLOUD } + FileDeleter.DeleteMode.FULL -> + files.count { + it.syncStatus == FileStatus.LOCAL || it.syncStatus == FileStatus.CLOUD + } + } + return DeleteReview(mode = mode, total = files.size, incompatibleCount = incompatible) +} + +/** Bouton dropdown « Supprimer ▼ » stylisé comme un `SelectionAction`. */ +@Composable +fun DeleteDropdownButton( + enabled: Boolean, + onModeSelected: (FileDeleter.DeleteMode) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val tint = if (enabled) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + + Box(modifier = modifier) { + Surface( + onClick = { if (enabled) expanded = true }, + enabled = enabled, + shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + color = if (enabled) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(width = 88.dp, height = 48.dp), + contentAlignment = Alignment.Center, + ) { + androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.delete), + color = tint, + fontSize = 11.sp, + maxLines = 1, + fontWeight = FontWeight.Bold, + ) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + } + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.delete_locally)) }, + onClick = { + expanded = false + onModeSelected(FileDeleter.DeleteMode.LOCALLY) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.delete_in_cloud)) }, + onClick = { + expanded = false + onModeSelected(FileDeleter.DeleteMode.IN_CLOUD) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.delete_full)) }, + onClick = { + expanded = false + onModeSelected(FileDeleter.DeleteMode.FULL) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } +} + +/** Modale de confirmation avant suppression. */ +@Composable +fun DeleteConfirmDialog( + count: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.delete_confirm_title, count), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = stringResource(R.string.delete_confirm_message), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.delete_confirm_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.delete_cancel)) + } + }, + ) +} + +/** Modale d\'avertissement quand certains fichiers ne sont pas synchronisés. */ +@Composable +fun DeleteWarningDialog( + review: DeleteReview, + onContinue: () -> Unit, + onDismiss: () -> Unit, +) { + val message = when (review.mode) { + FileDeleter.DeleteMode.IN_CLOUD -> stringResource( + R.string.delete_warning_local_only_msg, + review.incompatibleCount, + review.total, + ) + FileDeleter.DeleteMode.LOCALLY -> stringResource( + R.string.delete_warning_cloud_only_msg, + review.incompatibleCount, + review.total, + ) + FileDeleter.DeleteMode.FULL -> { + val localOnly = review.incompatibleCount + stringResource( + R.string.delete_warning_local_only_msg, + localOnly, + review.total, + ) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.delete_warning_title), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onContinue) { + Text( + text = stringResource(R.string.delete_confirm_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.delete_cancel)) + } + }, + ) +} diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt index 4fb26f3..365faf5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt @@ -143,6 +143,7 @@ fun SwipeReviewScreen( ReviewActions( onKeep = { state.cards.firstOrNull()?.let(viewModel::keep) }, onDelete = { state.cards.firstOrNull()?.let(viewModel::delete) }, + keepHint = stringResource(R.string.review_keep_hint), modifier = Modifier.padding(top = 16.dp), ) @@ -199,6 +200,7 @@ fun SwipeReviewScreen( private fun ReviewActions( onKeep: () -> Unit, onDelete: () -> Unit, + keepHint: String? = null, modifier: Modifier = Modifier, ) { Row( @@ -209,6 +211,7 @@ private fun ReviewActions( ReviewAction( icon = { tint, size -> Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.review_delete), tint = tint, modifier = Modifier.size(size)) }, label = stringResource(R.string.review_delete), + hint = null, containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, onClick = onDelete, @@ -216,6 +219,7 @@ private fun ReviewActions( ReviewAction( icon = { tint, size -> Icon(imageVector = Icons.Filled.Check, contentDescription = stringResource(R.string.review_keep), tint = tint, modifier = Modifier.size(size)) }, label = stringResource(R.string.review_keep), + hint = keepHint, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, onClick = onKeep, @@ -227,6 +231,7 @@ private fun ReviewActions( private fun ReviewAction( icon: @Composable (tint: Color, size: Dp) -> Unit, label: String, + hint: String?, containerColor: Color, contentColor: Color, onClick: () -> Unit, @@ -247,6 +252,14 @@ private fun ReviewAction( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), ) + if (hint != null) { + Text( + text = hint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } } } diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 43d068e..5a31d26 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -12,19 +12,20 @@ Dossiers Dashboard à venir %1$d document(s) à traiter - Révise-les en mode swipe : garder ou supprimer. + Révise-les en mode swipe : garder (et synchroniser) ou supprimer. Tout est à jour — aucun document à traiter. Nouveaux documents %1$d restant(s) GARDER + garder + synchroniser SUPPRIMER Tous les documents ont été traités. Terminer Tout marquer comme traité Tout marquer comme traité ? - Les documents restants seront considérés comme traités sans être revus. + Les documents restants seront considérés comme traités et synchronisés vers le cloud sans être revus. Impossible de charger les documents à traiter. Impossible de supprimer ce document — permission refusée. Remonter diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/local/dao/ProcessedGateTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/local/dao/ProcessedGateTest.kt new file mode 100644 index 0000000..cf57274 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/local/dao/ProcessedGateTest.kt @@ -0,0 +1,211 @@ +package com.vaultdrop.mobile.data.local.dao + +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.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.repository.FileRepository +import com.vaultdrop.mobile.data.repository.OutboxRepository +import com.vaultdrop.mobile.data.repository.SaveFileInput +import com.vaultdrop.mobile.domain.GenerateId +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +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.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +/** + * Contrat du gate « processed » — un fichier SAF n'est poussé vers le serveur + * (`create_resource` dans l'outbox) qu'une fois « gardé » : + * - ingestion SAF (`processed = false`) → importé localement, **aucune** op ; + * - scan export (`processed = true`) → poussé immédiatement ; + * - `markProcessed` (garder) → enqueue, idempotent (jamais deux fois) ; + * - `markAllProcessed` (échappatoire) → enqueue pour chaque fichier ; + * - `hasCreateOperation` discrimine pending/synced (vrai) vs absent/failed (faux). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ProcessedGateTest { + + private lateinit var db: AppDatabase + private lateinit var fileDao: FileDao + private lateinit var opsDao: PendingOperationDao + private lateinit var fileRepository: FileRepository + private lateinit var outboxRepository: OutboxRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + fileDao = db.fileDao() + opsDao = db.pendingOperationDao() + + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + val apiService = Retrofit.Builder() + .baseUrl("http://localhost:1/") + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(ApiService::class.java) + val apiClient = ApiClient(apiService, moshi) + val generateId = GenerateId() + + outboxRepository = OutboxRepository(opsDao, generateId, moshi) + fileRepository = FileRepository(fileDao, apiClient, generateId, db, outboxRepository) + } + + @After + fun tearDown() { + db.close() + } + + // --- ingestion ----------------------------------------------------------- + + @Test + fun nouveau_fichier_saf_non_traite_aucune_op_enqueue() = runTest { + saveLocal(processed = false, uri = FILE_URI) + + val ops = opsDao.selectPending(20) + assertTrue("un fichier SAF non traité ne doit rien pousser", ops.isEmpty()) + val file = fileDao.getByUri(FILE_URI)!! + assertEquals(false, file.processed) + } + + @Test + fun fichier_scan_deja_traite_pousse_immediatement() = runTest { + saveLocal(processed = true) + + val ops = opsDao.selectPending(20) + assertEquals(1, ops.size) + assertEquals(PendingOperationType.CREATE_RESOURCE, ops[0].operation) + assertEquals("file", ops[0].resourceType) + } + + // --- garder (markProcessed) --------------------------------------------- + + @Test + fun garder_enqueue_create_resource_une_seule_fois() = runTest { + saveLocal(processed = false, uri = FILE_URI) + val file = fileDao.getByUri(FILE_URI)!! + + fileRepository.markProcessed(file.resourceId) + assertEquals(1, selectOps(file.resourceId).size) + assertEquals(true, fileDao.getByResourceId(file.resourceId)!!.processed) + + // Idempotent : re-garder (double tap) ne re-enqueue pas. + fileRepository.markProcessed(file.resourceId) + assertEquals(1, selectOps(file.resourceId).size) + } + + @Test + fun garder_un_fichier_deja_pousse_ne_re_enqueue_pas() = runTest { + // Fichier scan (déjà `processed`, create déjà enqueue à l'ingestion). + saveLocal(processed = true) + val file = fileDao.getByResourceId(RESOURCE_ID)!! + assertEquals(1, selectOps(file.resourceId).size) + + fileRepository.markProcessed(file.resourceId) + assertEquals(1, selectOps(file.resourceId).size) + } + + // --- tout marquer (markAllProcessed) ------------------------------------ + + @Test + fun tout_marquer_enqueue_un_create_par_fichier() = runTest { + saveLocal(processed = false, uri = "content://tree/f1", resourceId = "a".repeat(32)) + saveLocal(processed = false, uri = "content://tree/f2", resourceId = "b".repeat(32)) + + fileRepository.markAllProcessed() + + assertEquals(1, selectOps("a".repeat(32)).size) + assertEquals(1, selectOps("b".repeat(32)).size) + assertEquals(true, fileDao.getByResourceId("a".repeat(32))!!.processed) + assertEquals(true, fileDao.getByResourceId("b".repeat(32))!!.processed) + } + + // --- hasCreateOperation -------------------------------------------------- + + @Test + fun hasCreateOperation_discrimine_pending_synced_absent_failed() = runTest { + val pending = "0".repeat(32) + val synced = "1".repeat(32) + val failed = "2".repeat(32) + + opsDao.insert(op(resourceId = pending, operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.PENDING)) + opsDao.insert(op(resourceId = synced, operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED)) + opsDao.insert(op(resourceId = failed, operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.FAILED)) + + assertTrue(outboxRepository.hasCreateOperation(pending)) + assertTrue(outboxRepository.hasCreateOperation(synced)) + assertEquals(false, outboxRepository.hasCreateOperation(failed)) + assertEquals(false, outboxRepository.hasCreateOperation("9".repeat(32))) + } + + @Test + fun un_delete_resource_ne_compte_pas_comme_create() = runTest { + opsDao.insert(op(resourceId = RESOURCE_ID, operation = PendingOperationType.DELETE_RESOURCE, status = PendingOpStatus.SYNCED)) + assertEquals(false, outboxRepository.hasCreateOperation(RESOURCE_ID)) + } + + // --- fixtures ------------------------------------------------------------ + + private fun saveLocal(processed: Boolean, uri: String = "content://tree/file", resourceId: String = RESOURCE_ID) { + runBlockingSafe { + fileRepository.saveLocalFile( + input = SaveFileInput( + uri = uri, + name = "$resourceId.txt", + extension = "txt", + size = 1024, + mimeType = "text/plain", + resourceId = resourceId, + processed = processed, + ), + folderResourceId = FOLDER, + ) + } + } + + private fun selectOps(resourceId: String): List = + runBlockingSafe { opsDao.selectPending(100) }.filter { it.resourceId == resourceId } + + private fun op( + resourceId: String, + operation: String, + status: String, + ) = PendingOperationEntity( + operationId = operationIdSeq.format(), + resourceId = resourceId, + resourceType = "file", + operation = operation, + payload = "{}", + status = status, + createdAt = NOW, + updatedAt = NOW, + ) + + private var opSeq = 0 + private val operationIdSeq: String get() = String.format("%032x", opSeq++) + private fun runBlockingSafe(block: suspend () -> T): T = kotlinx.coroutines.runBlocking { block() } + + private companion object { + const val NOW = 1_700_000_000_000L + const val FOLDER = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0" + const val RESOURCE_ID = "aabbccddeeff11223344556677889900" + const val FILE_URI = "content://tree/aabbccddeeff11223344556677889900" + } +} \ No newline at end of file