delete files
This commit is contained in:
+14
@@ -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
|
||||
}
|
||||
+53
-7
@@ -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<FileEntity> = 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",
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
@@ -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,8 +49,14 @@ class SafFileDeleter @Inject constructor(
|
||||
val now = System.currentTimeMillis()
|
||||
appDatabase.withTransaction {
|
||||
fileRepository.markMissing(file.resourceId, now)
|
||||
// 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 {
|
||||
Timber.w("deleteDocument returned false for %s", uri)
|
||||
|
||||
+265
@@ -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(
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,20 @@
|
||||
<string name="view_folders">Dossiers</string>
|
||||
<string name="dashboard_coming_soon">Dashboard à venir</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>
|
||||
|
||||
<!-- Mode review (swipe) -->
|
||||
<string name="review_title">Nouveaux documents</string>
|
||||
<string name="review_remaining">%1$d restant(s)</string>
|
||||
<string name="review_keep">GARDER</string>
|
||||
<string name="review_keep_hint">garder + synchroniser</string>
|
||||
<string name="review_delete">SUPPRIMER</string>
|
||||
<string name="review_done">Tous les documents ont été traités.</string>
|
||||
<string name="review_finish">Terminer</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_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_delete_error">Impossible de supprimer ce document — permission refusée.</string>
|
||||
<string name="browse_up">Remonter</string>
|
||||
|
||||
+211
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user