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")
|
@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
|
||||||
}
|
}
|
||||||
+53
-7
@@ -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",
|
||||||
|
|||||||
+8
@@ -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 {
|
||||||
|
|||||||
+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(
|
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>
|
||||||
|
|||||||
+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