display thumbnails

This commit is contained in:
m
2026-09-15 10:28:03 +02:00
parent 3499a3e356
commit 4659f3abc5
4 changed files with 487 additions and 0 deletions
@@ -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
}
@@ -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,
)
}
@@ -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,
)
}