From 4659f3abc5eadb0ddab10dabffadbb681ea4e6fa Mon Sep 17 00:00:00 2001 From: m Date: Tue, 15 Sep 2026 10:28:03 +0200 Subject: [PATCH] display thumbnails --- .../features/thumbnails/ThumbnailStore.kt | 197 ++++++++++++++++++ .../mobile/ui/components/FileThumbnail.kt | 91 ++++++++ .../features/thumbnails/ThumbnailStoreTest.kt | 146 +++++++++++++ .../mobile/ui/document/DeleteModesForTest.kt | 53 +++++ 4 files changed, 487 insertions(+) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStore.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/FileThumbnail.kt create mode 100644 mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStoreTest.kt create mode 100644 mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/document/DeleteModesForTest.kt diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStore.kt new file mode 100644 index 0000000..32eeeb6 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStore.kt @@ -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/.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() + + /** 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" + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/FileThumbnail.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/FileThumbnail.kt new file mode 100644 index 0000000..86e8d9c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/FileThumbnail.kt @@ -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(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 +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStoreTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStoreTest.kt new file mode 100644 index 0000000..b4b39a1 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/features/thumbnails/ThumbnailStoreTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/document/DeleteModesForTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/document/DeleteModesForTest.kt new file mode 100644 index 0000000..43ea566 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/document/DeleteModesForTest.kt @@ -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(), 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, + ) +} \ No newline at end of file