delete files

This commit is contained in:
m
2026-09-14 09:42:19 +02:00
parent 7fff65af4f
commit e0fd9ce99a
9 changed files with 703 additions and 10 deletions
@@ -42,6 +42,20 @@ interface PendingOperationDao {
@Query("DELETE FROM pending_operations WHERE status = 'synced' AND updated_at < :cutoff") @Query("DELETE FROM pending_operations WHERE status = 'synced' AND updated_at < :cutoff")
suspend fun purgeSynced(cutoff: Long) 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'") @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
suspend fun countPending(): Int suspend fun countPending(): Int
} }
@@ -60,12 +60,55 @@ class FileRepository @Inject constructor(
/** Snapshot de la file de review, chargé à l'entrée dans le mode traitement. */ /** Snapshot de la file de review, chargé à l'entrée dans le mode traitement. */
suspend fun getUnprocessed(): List<FileEntity> = fileDao.getUnprocessed() suspend fun getUnprocessed(): List<FileEntity> = fileDao.getUnprocessed()
/** Marque un fichier comme traité (gardé) — local au device, jamais poussé. */ /**
suspend fun markProcessed(resourceId: String) = * Marque un fichier comme traité (gardé) — déclenche aussi le push du
fileDao.markProcessed(resourceId, System.currentTimeMillis()) * `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? = suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId) fileDao.getByResourceId(resourceId)
@@ -109,8 +152,11 @@ class FileRepository @Inject constructor(
) )
appDatabase.withTransaction { appDatabase.withTransaction {
fileDao.upsert(entity) fileDao.upsert(entity)
if (existing == null) { if (existing == null && entity.processed) {
// Nouveau fichier physique → le pousser vers le serveur (métadonnées). // 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( outboxRepository.enqueueCreateResource(
resourceId = entity.resourceId, resourceId = entity.resourceId,
resourceType = "file", resourceType = "file",
@@ -105,4 +105,12 @@ class OutboxRepository @Inject constructor(
/** Nombre d'ops en attente de push (stats UI optionnelles). */ /** Nombre d'ops en attente de push (stats UI optionnelles). */
suspend fun countPending(): Int = pendingOperationDao.countPending() 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
} }
@@ -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<FileEntity>, 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)
}
}
}
@@ -49,7 +49,13 @@ class SafFileDeleter @Inject constructor(
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
appDatabase.withTransaction { appDatabase.withTransaction {
fileRepository.markMissing(file.resourceId, now) 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) Timber.d("deleted %s", uri)
} else { } else {
@@ -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<FileEntity>, 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))
}
},
)
}
@@ -143,6 +143,7 @@ fun SwipeReviewScreen(
ReviewActions( ReviewActions(
onKeep = { state.cards.firstOrNull()?.let(viewModel::keep) }, onKeep = { state.cards.firstOrNull()?.let(viewModel::keep) },
onDelete = { state.cards.firstOrNull()?.let(viewModel::delete) }, onDelete = { state.cards.firstOrNull()?.let(viewModel::delete) },
keepHint = stringResource(R.string.review_keep_hint),
modifier = Modifier.padding(top = 16.dp), modifier = Modifier.padding(top = 16.dp),
) )
@@ -199,6 +200,7 @@ fun SwipeReviewScreen(
private fun ReviewActions( private fun ReviewActions(
onKeep: () -> Unit, onKeep: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
keepHint: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -209,6 +211,7 @@ private fun ReviewActions(
ReviewAction( ReviewAction(
icon = { tint, size -> Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.review_delete), tint = tint, modifier = Modifier.size(size)) }, 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), label = stringResource(R.string.review_delete),
hint = null,
containerColor = MaterialTheme.colorScheme.errorContainer, containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer,
onClick = onDelete, onClick = onDelete,
@@ -216,6 +219,7 @@ private fun ReviewActions(
ReviewAction( ReviewAction(
icon = { tint, size -> Icon(imageVector = Icons.Filled.Check, contentDescription = stringResource(R.string.review_keep), tint = tint, modifier = Modifier.size(size)) }, 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), label = stringResource(R.string.review_keep),
hint = keepHint,
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
onClick = onKeep, onClick = onKeep,
@@ -227,6 +231,7 @@ private fun ReviewActions(
private fun ReviewAction( private fun ReviewAction(
icon: @Composable (tint: Color, size: Dp) -> Unit, icon: @Composable (tint: Color, size: Dp) -> Unit,
label: String, label: String,
hint: String?,
containerColor: Color, containerColor: Color,
contentColor: Color, contentColor: Color,
onClick: () -> Unit, onClick: () -> Unit,
@@ -247,6 +252,14 @@ private fun ReviewAction(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp), 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),
)
}
} }
} }
@@ -12,19 +12,20 @@
<string name="view_folders">Dossiers</string> <string name="view_folders">Dossiers</string>
<string name="dashboard_coming_soon">Dashboard à venir</string> <string name="dashboard_coming_soon">Dashboard à venir</string>
<string name="dashboard_review_title">%1$d document(s) à traiter</string> <string name="dashboard_review_title">%1$d document(s) à traiter</string>
<string name="dashboard_review_hint">Révise-les en mode swipe : garder ou supprimer.</string> <string name="dashboard_review_hint">Révise-les en mode swipe : garder (et synchroniser) ou supprimer.</string>
<string name="dashboard_all_processed">Tout est à jour — aucun document à traiter.</string> <string name="dashboard_all_processed">Tout est à jour — aucun document à traiter.</string>
<!-- Mode review (swipe) --> <!-- Mode review (swipe) -->
<string name="review_title">Nouveaux documents</string> <string name="review_title">Nouveaux documents</string>
<string name="review_remaining">%1$d restant(s)</string> <string name="review_remaining">%1$d restant(s)</string>
<string name="review_keep">GARDER</string> <string name="review_keep">GARDER</string>
<string name="review_keep_hint">garder + synchroniser</string>
<string name="review_delete">SUPPRIMER</string> <string name="review_delete">SUPPRIMER</string>
<string name="review_done">Tous les documents ont été traités.</string> <string name="review_done">Tous les documents ont été traités.</string>
<string name="review_finish">Terminer</string> <string name="review_finish">Terminer</string>
<string name="review_mark_all">Tout marquer comme traité</string> <string name="review_mark_all">Tout marquer comme traité</string>
<string name="review_mark_all_title">Tout marquer comme traité ?</string> <string name="review_mark_all_title">Tout marquer comme traité ?</string>
<string name="review_mark_all_message">Les documents restants seront considérés comme traités sans être revus.</string> <string name="review_mark_all_message">Les documents restants seront considérés comme traités et synchronisés vers le cloud sans être revus.</string>
<string name="review_load_error">Impossible de charger les documents à traiter.</string> <string name="review_load_error">Impossible de charger les documents à traiter.</string>
<string name="review_delete_error">Impossible de supprimer ce document — permission refusée.</string> <string name="review_delete_error">Impossible de supprimer ce document — permission refusée.</string>
<string name="browse_up">Remonter</string> <string name="browse_up">Remonter</string>
@@ -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<Context>()
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<PendingOperationEntity> =
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 <T> 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"
}
}