display thumbnails
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
|||||||
|
package com.vaultdrop.mobile.features.thumbnails
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
|
import com.vaultdrop.mobile.ui.components.categoryValue
|
||||||
|
import com.vaultdrop.mobile.ui.document.content.DocumentContent
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignettes locales des documents, stockées sous forme de petits fichiers WebP
|
||||||
|
* dans `filesDir/thumbnails/<resourceId>.webp` — nom dérivé de l'identité
|
||||||
|
* canonique, aucune colonne Room.
|
||||||
|
*
|
||||||
|
* - **génération lazy** : uniquement pour les items visibles, à la demande
|
||||||
|
* depuis l'UI (`ensure`), single-flight par resourceId pour qu'une grille
|
||||||
|
* ne décode jamais deux fois le même fichier ;
|
||||||
|
* - **décodage borné** : jamais le bitmap plein-résolution — côté max
|
||||||
|
* [MAX_EDGE_PX] (512 px), en `inSampleSize` pour les images et page 0
|
||||||
|
* capée via `PdfRenderer` pour les PDF ;
|
||||||
|
* - **cache-hit à zéro SAF** : le thumbnail en place et plus récent que la
|
||||||
|
* source (`lastModified`) est retourné sans toucher au `ContentResolver` ;
|
||||||
|
* un fichier source modifié régénère à la volée (mtime compare) ;
|
||||||
|
* - **invalidation** : purge au delete des ressources (deleters).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ThumbnailStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val dir: File get() = dir()
|
||||||
|
|
||||||
|
/** Clefs non-Android partagées en interne pour les tests. */
|
||||||
|
internal val maxEdgePx: Int = MAX_EDGE_PX
|
||||||
|
internal val webpQuality: Int = WEBP_QUALITY
|
||||||
|
|
||||||
|
/** Single-flight par resourceId — une génération en cours = pas de doublon. */
|
||||||
|
private val locks = ConcurrentHashMap<String, Mutex>()
|
||||||
|
|
||||||
|
/** Fichier cible d'une resource — indépendant de la catégorie. */
|
||||||
|
internal fun thumbnailFile(resourceId: String): File =
|
||||||
|
File(dir(), "$resourceId.webp")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignette du fichier, générée si absente ou périmée. `null` si le fichier
|
||||||
|
* n'est ni image ni PDF, n'a pas d'uri, ou si la génération échoue
|
||||||
|
* (provider / PDF corrompu) — l'UI retombe alors sur l'icône de catégorie.
|
||||||
|
*/
|
||||||
|
suspend fun ensure(file: FileEntity): File? {
|
||||||
|
val uri = file.uri ?: return null
|
||||||
|
val category = file.categoryValue()
|
||||||
|
if (category != FileCategory.IMAGE && category != FileCategory.PDF) return null
|
||||||
|
|
||||||
|
val target = thumbnailFile(file.resourceId)
|
||||||
|
if (isUpToDate(target, file.lastModified)) return target
|
||||||
|
|
||||||
|
val lock = locks.getOrPut(file.resourceId) { Mutex() }
|
||||||
|
return lock.withLock {
|
||||||
|
if (isUpToDate(target, file.lastModified)) target
|
||||||
|
else generateInto(context.contentResolver, uri, category, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Purge la vignette d'une resource (delete physique) — best-effort. */
|
||||||
|
fun delete(resourceId: String) {
|
||||||
|
runCatching { thumbnailFile(resourceId).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache-hit : fichier présent et au moins aussi récent que la source. */
|
||||||
|
internal fun isUpToDate(target: File, lastModified: Long?): Boolean {
|
||||||
|
if (!target.isFile) return false
|
||||||
|
if (lastModified == null) return true
|
||||||
|
return target.lastModified() >= lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génération brute : décode borné puis écriture WebP. Retourne `null` si KO. */
|
||||||
|
internal suspend fun generateInto(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: String,
|
||||||
|
category: FileCategory,
|
||||||
|
target: File,
|
||||||
|
): File? = withContext(Dispatchers.IO) {
|
||||||
|
val bitmap = when (category) {
|
||||||
|
FileCategory.IMAGE -> decodeImage(resolver, uri)
|
||||||
|
FileCategory.PDF -> renderPdfPage(resolver, uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
bitmap ?: return@withContext null
|
||||||
|
try {
|
||||||
|
dir().mkdirs()
|
||||||
|
val tmp = File(target.parentFile, "${target.name}.tmp")
|
||||||
|
val ok = tmp.outputStream().use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.WEBP, WEBP_QUALITY, out)
|
||||||
|
}
|
||||||
|
if (ok && target.exists()) {
|
||||||
|
// La source a bougé pendant notre génération : on garde la dernière.
|
||||||
|
target.delete()
|
||||||
|
}
|
||||||
|
if (ok && tmp.renameTo(target)) target else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "thumbnail write failed for $uri")
|
||||||
|
runCatching { target.delete() }
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image : décodage en `inSampleSize` pour ne jamais matérialiser le full-res. */
|
||||||
|
private fun decodeImage(resolver: ContentResolver, uri: String): Bitmap? {
|
||||||
|
val stream = DocumentContent.openInputStream(resolver, uri) ?: return null
|
||||||
|
return try {
|
||||||
|
// Première passe : dimensions seulement, sans décodage pixels.
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
stream.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||||
|
val sample = inSampleSizeFor(bounds.outWidth, bounds.outHeight)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val stream2 = DocumentContent.openInputStream(resolver, uri) ?: return null
|
||||||
|
stream2.use {
|
||||||
|
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
BitmapFactory.decodeStream(it, null, opts)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "thumbnail decode failed for $uri")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun inSampleSizeFor(width: Int, height: Int): Int {
|
||||||
|
if (width <= 0 || height <= 0) return 1
|
||||||
|
var sample = 1
|
||||||
|
while ((width / sample) > MAX_EDGE_PX || (height / sample) > MAX_EDGE_PX) {
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF : rendu de la page 0 en `ARGB_8888`, dimensions gardées en
|
||||||
|
* proportion et capées à [MAX_EDGE_PX] (pattern de `PdfDocumentState`).
|
||||||
|
* Le renderer est refermé immédiatement — pas de cache LRU ici.
|
||||||
|
*/
|
||||||
|
private fun renderPdfPage(resolver: ContentResolver, uri: String): Bitmap? {
|
||||||
|
val pfd = DocumentContent.openFileDescriptor(resolver, uri) ?: return null
|
||||||
|
var renderer: PdfRenderer? = null
|
||||||
|
return try {
|
||||||
|
val opened = PdfRenderer(pfd)
|
||||||
|
renderer = opened
|
||||||
|
if (opened.pageCount < 1) return null
|
||||||
|
val page = opened.openPage(0)
|
||||||
|
try {
|
||||||
|
val scale = minOf(
|
||||||
|
MAX_EDGE_PX.toFloat() / page.width.toFloat(),
|
||||||
|
MAX_EDGE_PX.toFloat() / page.height.toFloat(),
|
||||||
|
).coerceAtMost(1f)
|
||||||
|
val width = (page.width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val height = (page.height * scale).toInt().coerceAtLeast(1)
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
bitmap
|
||||||
|
} finally {
|
||||||
|
page.close()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "thumbnail pdf render failed for $uri")
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
runCatching { renderer?.close() }
|
||||||
|
runCatching { pfd.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dir(): File {
|
||||||
|
val d = File(context.filesDir, THUMBNAIL_DIR)
|
||||||
|
if (!d.exists()) d.mkdirs()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_EDGE_PX = 512
|
||||||
|
const val WEBP_QUALITY = 80
|
||||||
|
const val THUMBNAIL_DIR = "thumbnails"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
|
import com.vaultdrop.mobile.features.thumbnails.ThumbnailStore
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignette réelle d'un document dans les listes (accueil + dossiers).
|
||||||
|
*
|
||||||
|
* Images (JPEG/PNG/WebP) et PDF (page 1) n'affichent plus une simple icône :
|
||||||
|
* la vignette est générée à la demande par [ThumbnailStore] (décodage borné
|
||||||
|
* 512 px, single-flight, cache disque `filesDir/thumbnails`) puis rendue par
|
||||||
|
* Coil depuis le fichier local — zéro lecture SAF au scroll. Les autres
|
||||||
|
* catégories et les fichiers cloud-only conservent l'icône de catégorie.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FileThumbnail(
|
||||||
|
file: FileEntity,
|
||||||
|
size: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
shape: Shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
val category = file.categoryValue()
|
||||||
|
if (category != FileCategory.IMAGE && category != FileCategory.PDF) {
|
||||||
|
FileCategoryIcon(file = file, size = size, modifier = modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val thumbnailStore = rememberThumbnailStore(context)
|
||||||
|
val thumb by produceState<File?>(null, file.resourceId, file.lastModified) {
|
||||||
|
value = withContext(Dispatchers.IO) { thumbnailStore.ensure(file) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val thumbFile = thumb
|
||||||
|
if (thumbFile == null) {
|
||||||
|
FileCategoryIcon(file = file, size = size, modifier = modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizePx = (size.value * context.resources.displayMetrics.density).toInt()
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(thumbFile)
|
||||||
|
.size(sizePx)
|
||||||
|
.build(),
|
||||||
|
contentDescription = file.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(shape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberThumbnailStore(context: Context): ThumbnailStore =
|
||||||
|
remember(context) {
|
||||||
|
EntryPointAccessors.fromApplication(
|
||||||
|
context.applicationContext,
|
||||||
|
ThumbnailStoreEntryPoint::class.java,
|
||||||
|
).thumbnailStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal interface ThumbnailStoreEntryPoint {
|
||||||
|
fun thumbnailStore(): ThumbnailStore
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.vaultdrop.mobile.features.thumbnails
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
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 java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du ThumbnailStore — les coûts ressources sont bornés par design :
|
||||||
|
* - `inSampleSizeFor` garantit un décodage ≤ 512 px (jamais le full-res) ;
|
||||||
|
* - le cache-hit (`isUpToDate`) ne touche ni ContentResolver ni disque lourd ;
|
||||||
|
* - `ensure` ne génère que pour IMAGE/PDF avec uri (les autres → null, icône) ;
|
||||||
|
* - `delete` purge la vignette d'une ressource supprimée.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class ThumbnailStoreTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var store: ThumbnailStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
store = ThumbnailStore(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inSampleSize_dimensionne_toujours_sous_la_bome() {
|
||||||
|
// 12800 × 7200 (12MP) → décodé ≤ 512 px de côté max.
|
||||||
|
assertEquals(32, store.inSampleSizeFor(12800, 7200))
|
||||||
|
// 4000 × 3000 → 8 → 500 px ≤ 512.
|
||||||
|
assertEquals(8, store.inSampleSizeFor(4000, 3000))
|
||||||
|
// Petites images : pas de sur-échantillonnage inutile.
|
||||||
|
assertEquals(1, store.inSampleSizeFor(400, 300))
|
||||||
|
// Dimensions invalides → 1 (aucun downsample défensif).
|
||||||
|
assertEquals(1, store.inSampleSizeFor(0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thumbnailFile_nomme_par_resource_id() {
|
||||||
|
val id = "a".repeat(32)
|
||||||
|
val f = store.thumbnailFile(id)
|
||||||
|
assertEquals(
|
||||||
|
File(File(context.filesDir, "thumbnails"), "$id.webp").absolutePath,
|
||||||
|
f.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isUpToDate_absente_faux() {
|
||||||
|
assertFalse(store.isUpToDate(store.thumbnailFile("0".repeat(32)), 123L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isUpToDate_plus_recente_que_la_source_vrai() {
|
||||||
|
val target = store.thumbnailFile("1".repeat(32))
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
target.writeBytes(ByteArray(4))
|
||||||
|
assertTrue(target.setLastModified(10_000L))
|
||||||
|
|
||||||
|
assertTrue(store.isUpToDate(target, 10_000L))
|
||||||
|
assertTrue(store.isUpToDate(target, 5_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isUpToDate_source_plus_recente_faux() {
|
||||||
|
val target = store.thumbnailFile("2".repeat(32))
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
target.writeBytes(ByteArray(4))
|
||||||
|
assertTrue(target.setLastModified(10_000L))
|
||||||
|
|
||||||
|
assertFalse(store.isUpToDate(target, 20_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensure_sans_uri_retourne_null() {
|
||||||
|
val file = fileWith(resourceId = "3".repeat(32), category = "IMAGE", uri = null)
|
||||||
|
runTest { assertNull(store.ensure(file)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensure_categorie_hors_image_pdf_retourne_null() {
|
||||||
|
val file = fileWith(resourceId = "4".repeat(32), category = "TEXT", uri = "content://root/doc")
|
||||||
|
runTest { assertNull(store.ensure(file)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensure_vignette_en_place_retournee_sans_generation() {
|
||||||
|
val resourceId = "5".repeat(32)
|
||||||
|
val file = fileWith(
|
||||||
|
resourceId = resourceId,
|
||||||
|
category = "IMAGE",
|
||||||
|
uri = "content://root/doc",
|
||||||
|
lastModified = 10_000L,
|
||||||
|
)
|
||||||
|
val existing = store.thumbnailFile(resourceId)
|
||||||
|
existing.parentFile?.mkdirs()
|
||||||
|
existing.writeBytes(ByteArray(4))
|
||||||
|
existing.setLastModified(20_000L)
|
||||||
|
|
||||||
|
runTest {
|
||||||
|
val thumb = store.ensure(file)
|
||||||
|
assertEquals(existing.absolutePath, thumb?.absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delete_purge_la_vignette() {
|
||||||
|
val resourceId = "6".repeat(32)
|
||||||
|
val target = store.thumbnailFile(resourceId)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
target.writeBytes(ByteArray(4))
|
||||||
|
assertTrue(target.isFile)
|
||||||
|
|
||||||
|
store.delete(resourceId)
|
||||||
|
assertFalse(target.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileWith(
|
||||||
|
resourceId: String,
|
||||||
|
category: String?,
|
||||||
|
uri: String?,
|
||||||
|
lastModified: Long? = null,
|
||||||
|
) = FileEntity(
|
||||||
|
resourceId = resourceId,
|
||||||
|
name = "doc",
|
||||||
|
folderResourceId = "f".repeat(32),
|
||||||
|
size = 1L,
|
||||||
|
uri = uri,
|
||||||
|
category = category,
|
||||||
|
lastModified = lastModified,
|
||||||
|
addedAt = 0L,
|
||||||
|
updatedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.document
|
||||||
|
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileStatus
|
||||||
|
import com.vaultdrop.mobile.features.saf.FileDeleter
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeleteModesForTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun local_offre_uniquement_la_suppression_local() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(FileDeleter.DeleteMode.LOCALLY),
|
||||||
|
deleteModesFor(file(syncStatus = FileStatus.LOCAL)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cloud_offre_uniquement_la_suppression_cloud() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(FileDeleter.DeleteMode.IN_CLOUD),
|
||||||
|
deleteModesFor(file(syncStatus = FileStatus.CLOUD)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun local_cloud_offre_les_trois_modes() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
FileDeleter.DeleteMode.LOCALLY,
|
||||||
|
FileDeleter.DeleteMode.IN_CLOUD,
|
||||||
|
FileDeleter.DeleteMode.FULL,
|
||||||
|
),
|
||||||
|
deleteModesFor(file(syncStatus = FileStatus.LOCAL_CLOUD)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun statut_inconnu_ne_propose_aucun_mode() {
|
||||||
|
assertEquals(emptyList<FileDeleter.DeleteMode>(), deleteModesFor(file(syncStatus = "inconnu")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun file(syncStatus: String) = FileEntity(
|
||||||
|
resourceId = "0".repeat(32),
|
||||||
|
name = "a.pdf",
|
||||||
|
folderResourceId = "1".repeat(32),
|
||||||
|
size = 1L,
|
||||||
|
syncStatus = syncStatus,
|
||||||
|
addedAt = 0L,
|
||||||
|
updatedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user