From 9b356d31ba134c8a01cd05dccae6aa42a6fd0cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 03:06:52 +0000 Subject: [PATCH 1/6] feat: add thumbnail disk cache for profile pictures Profile pictures are displayed at small fixed sizes (18-100dp) but Coil's disk cache stores full-size originals, causing expensive re-decode on every memory cache miss. With 60 avatars on screen, this creates significant I/O. Adds a ProfilePictureFetcher with a dedicated ThumbnailDiskCache that stores pre-resized 256px JPEG thumbnails (~5-10KB each). On cache hit, reads a tiny file instead of re-decoding the full original. The zoomable full-screen dialog bypasses this cache and loads the original via the normal Coil pipeline. https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../com/vitorpamplona/amethyst/AppModules.kt | 9 + .../service/images/ImageLoaderSetup.kt | 3 + .../service/images/ProfilePictureFetcher.kt | 216 ++++++++++++++++++ .../service/images/ThumbnailDiskCache.kt | 88 +++++++ .../ui/components/RobohashAsyncImage.kt | 3 +- .../commons/ui/components/UserAvatar.kt | 15 +- 6 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7fcd4b3e9..0fb6b9a7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService import com.vitorpamplona.amethyst.service.images.ImageCacheFactory import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup +import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager @@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.ui.resourceCacheInit @@ -422,6 +424,12 @@ class AppModules( ImageCacheFactory.newMemory(appContext) } + // thumbnail disk cache for profile pictures + val thumbnailDiskCache: ThumbnailDiskCache by lazy { + Log.d("AppModules", "ThumbnailDiskCache Init") + ThumbnailDiskCache(appContext.safeCacheDir().resolve("profile_thumbnails")) + } + // crash report storage val crashReportCache = CrashReportCache(appContext) @@ -439,6 +447,7 @@ class AppModules( memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + thumbnailCache = thumbnailDiskCache, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 9e8b40a03..a5b3cbc37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -66,6 +66,7 @@ class ImageLoaderSetup { memoryCache: () -> MemoryCache, blossomServerResolver: () -> BlossomServerResolver, callFactory: (url: String) -> Call.Factory, + thumbnailCache: ThumbnailDiskCache, ) { SingletonImageLoader.setUnsafe( ImageLoader @@ -81,8 +82,10 @@ class ImageLoaderSetup { add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) add(BlossomFetcher.Factory(blossomServerResolver, callFactory)) + add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory)) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) + add(ProfilePictureFetcher.BKeyer) add(OkHttpFactory(callFactory)) }.build(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt new file mode 100644 index 000000000..2534df511 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.images + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.Uri +import coil3.annotation.ExperimentalCoilApi +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.fetch.SourceFetchResult +import coil3.key.Keyer +import coil3.network.CacheStrategy +import coil3.network.ConcurrentRequestStrategy +import coil3.network.ConnectivityChecker +import coil3.network.NetworkFetcher +import coil3.network.okhttp.asNetworkClient +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME +import okhttp3.Call +import kotlin.coroutines.cancellation.CancellationException + +/** + * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. + * + * URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. + * This fetcher: + * 1. Checks the thumbnail disk cache for a pre-resized JPEG (~5-10KB) + * 2. On hit: returns the tiny file directly (fast disk read) + * 3. On miss: downloads the original via NetworkFetcher, decodes + resizes to 256px, + * saves the thumbnail to the cache, and returns the small bitmap + * + * The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), + * which goes through the normal Coil pipeline for full-resolution display. + */ +@Stable +class ProfilePictureFetcher( + private val originalUrl: String, + private val options: Options, + private val thumbnailCache: ThumbnailDiskCache, + private val networkFetcher: Fetcher, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + // Check thumbnail cache first + val cached = thumbnailCache.get(originalUrl) + if (cached != null) { + val bitmap = thumbnailCache.decodeThumbnail(cached) + if (bitmap != null) { + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = true, + dataSource = DataSource.DISK, + ) + } + } + + // Cache miss: download via normal network fetcher + val result = + try { + networkFetcher.fetch() ?: return null + } catch (e: Exception) { + if (e is CancellationException) throw e + return null + } + + // Decode the downloaded image to a small thumbnail + val thumbnail = decodeAndResize(result) ?: return result + + // Save thumbnail to our cache + thumbnailCache.save(originalUrl, thumbnail) + + return ImageFetchResult( + image = thumbnail.asImage(true), + isSampled = true, + dataSource = DataSource.NETWORK, + ) + } + + private fun decodeAndResize(result: FetchResult): Bitmap? = + try { + when (result) { + is SourceFetchResult -> { + val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX + + // First pass: decode bounds only + val boundsOptions = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + val source = result.source + val bytes = source.source().use { it.readByteArray() } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) + + // Calculate inSampleSize for efficient decode + val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) + + // Second pass: decode at reduced size + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + } + val decoded = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) + ?: return null + + // Scale to exact target size + val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) + if (scaled !== decoded) { + decoded.recycle() + } + scaled + } + + is ImageFetchResult -> { + // Already decoded (e.g. from Base64Fetcher) — not expected for profile pics + null + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int, + ): Int { + val (height, width) = options.outHeight to options.outWidth + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + companion object { + const val SCHEME = PROFILE_PIC_SCHEME + + fun extractOriginalUrl(data: Uri): String { + // profilepic://https://example.com/pic.jpg → https://example.com/pic.jpg + val full = data.toString() + return full.removePrefix("$SCHEME://") + } + } + + @OptIn(ExperimentalCoilApi::class) + class Factory( + private val thumbnailCache: ThumbnailDiskCache, + private val networkClient: (url: String) -> Call.Factory, + ) : Fetcher.Factory { + private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) + + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? { + if (data.scheme != SCHEME) return null + + val originalUrl = extractOriginalUrl(data) + + val netFetcher = + NetworkFetcher( + url = originalUrl, + options = options, + networkClient = lazy { networkClient(originalUrl).asNetworkClient() }, + diskCache = lazy { imageLoader.diskCache }, + cacheStrategy = lazy { CacheStrategy.DEFAULT }, + connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, + concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + ) + + return ProfilePictureFetcher(originalUrl, options, thumbnailCache, netFetcher) + } + } + + object BKeyer : Keyer { + override fun key( + data: Uri, + options: Options, + ): String? = + if (data.scheme == SCHEME) { + "profilepic_thumb_${extractOriginalUrl(data)}" + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt new file mode 100644 index 000000000..8cb2a79e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.images + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File + +/** + * Disk cache for pre-resized profile picture thumbnails. + * + * Stores small JPEG thumbnails (typically ~5-10KB each) keyed by URL hash. + * This avoids re-decoding large original images from Coil's disk cache on + * every recomposition when images are evicted from the memory cache. + * + * With 60 profile pictures on screen, reading ~300-600KB of tiny thumbnails + * is dramatically faster than re-decoding 60 full-size originals. + */ +class ThumbnailDiskCache( + private val cacheDir: File, + private val maxEntries: Int = 10_000, +) { + companion object { + const val THUMBNAIL_SIZE_PX = 256 + const val JPEG_QUALITY = 80 + } + + init { + cacheDir.mkdirs() + } + + private fun keyFor(url: String): String = sha256(url.toByteArray()).toHexKey() + + fun get(url: String): File? { + val file = File(cacheDir, keyFor(url)) + return if (file.exists()) file else null + } + + fun save( + url: String, + bitmap: Bitmap, + ): File { + val file = File(cacheDir, keyFor(url)) + file.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + } + evictIfNeeded() + return file + } + + fun decodeThumbnail(file: File): Bitmap? = + try { + BitmapFactory.decodeFile(file.absolutePath) + } catch (e: Exception) { + file.delete() + null + } + + private fun evictIfNeeded() { + val files = cacheDir.listFiles() ?: return + if (files.size > maxEntries) { + files + .sortedBy { it.lastModified() } + .take(files.size - maxEntries) + .forEach { it.delete() } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 080484269..39aadfd1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter @@ -92,7 +93,7 @@ fun RobohashFallbackAsyncImage( } AsyncImage( - model = model, + model = "$PROFILE_PIC_SCHEME://$model", contentDescription = contentDescription, modifier = modifier, placeholder = painter, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index a80059647..d1fbd9d1f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -41,6 +41,8 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.ui.theme.isLight +const val PROFILE_PIC_SCHEME = "profilepic" + /** * Shared avatar component that displays a user's profile picture with Robohash fallback. * @@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.isLight * @param contentDescription Accessibility description * @param loadProfilePicture Whether to load the profile picture (false = show robohash only) * @param loadRobohash Whether to generate robohash (false = show generic icon) + * @param useThumbnailCache Whether to wrap the URL with profilepic:// scheme for thumbnail caching */ @Composable fun UserAvatar( @@ -61,6 +64,7 @@ fun UserAvatar( contentDescription: String? = null, loadProfilePicture: Boolean = true, loadRobohash: Boolean = true, + useThumbnailCache: Boolean = false, ) { val avatarModifier = remember(size, modifier) { @@ -69,7 +73,14 @@ fun UserAvatar( .clip(shape = CircleShape) } - if (pictureUrl != null && loadProfilePicture) { + val imageModel = + if (pictureUrl != null && useThumbnailCache) { + "$PROFILE_PIC_SCHEME://$pictureUrl" + } else { + pictureUrl + } + + if (imageModel != null && loadProfilePicture) { // Show profile picture with robohash/icon as fallback val fallbackPainter = if (loadRobohash) { @@ -83,7 +94,7 @@ fun UserAvatar( } AsyncImage( - model = pictureUrl, + model = imageModel, contentDescription = contentDescription, modifier = avatarModifier, placeholder = fallbackPainter, From 80ece9ac9d4b9e760f8351e4b40c34a271cb493a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 13:58:06 +0000 Subject: [PATCH 2/6] perf: run thumbnail generation in background on first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On cache miss, the fetcher now returns the decoded bitmap immediately for display while saving the thumbnail to disk in a background coroutine. This means the first load is just as fast as the normal Coil pipeline — no blocking on resize + JPEG compress + disk write. The thumbnail is ready for the next load. https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../com/vitorpamplona/amethyst/AppModules.kt | 1 + .../service/images/ImageLoaderSetup.kt | 4 +- .../service/images/ProfilePictureFetcher.kt | 105 ++++++++++-------- 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0fb6b9a7e..e7d99880c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -448,6 +448,7 @@ class AppModules( blossomServerResolver = { blossomResolver }, callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, thumbnailCache = thumbnailDiskCache, + backgroundScope = applicationIOScope, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index a5b3cbc37..31b4e782f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -45,6 +45,7 @@ import coil3.video.VideoFrameDecoder import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope import okhttp3.Call class ImageLoaderSetup { @@ -67,6 +68,7 @@ class ImageLoaderSetup { blossomServerResolver: () -> BlossomServerResolver, callFactory: (url: String) -> Call.Factory, thumbnailCache: ThumbnailDiskCache, + backgroundScope: CoroutineScope, ) { SingletonImageLoader.setUnsafe( ImageLoader @@ -82,7 +84,7 @@ class ImageLoaderSetup { add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) add(BlossomFetcher.Factory(blossomServerResolver, callFactory)) - add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory)) + add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope)) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) add(ProfilePictureFetcher.BKeyer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 2534df511..9d0aa638b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -40,6 +40,8 @@ import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.Options import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import okhttp3.Call import kotlin.coroutines.cancellation.CancellationException @@ -49,9 +51,9 @@ import kotlin.coroutines.cancellation.CancellationException * URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. * This fetcher: * 1. Checks the thumbnail disk cache for a pre-resized JPEG (~5-10KB) - * 2. On hit: returns the tiny file directly (fast disk read) - * 3. On miss: downloads the original via NetworkFetcher, decodes + resizes to 256px, - * saves the thumbnail to the cache, and returns the small bitmap + * 2. On hit: returns the tiny thumbnail directly (fast disk read) + * 3. On miss: returns the network result immediately for display, and generates + * the thumbnail in the background for next time * * The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), * which goes through the normal Coil pipeline for full-resolution display. @@ -62,9 +64,10 @@ class ProfilePictureFetcher( private val options: Options, private val thumbnailCache: ThumbnailDiskCache, private val networkFetcher: Fetcher, + private val backgroundScope: CoroutineScope, ) : Fetcher { override suspend fun fetch(): FetchResult? { - // Check thumbnail cache first + // Fast path: return pre-resized thumbnail from disk (~5KB read) val cached = thumbnailCache.get(originalUrl) if (cached != null) { val bitmap = thumbnailCache.decodeThumbnail(cached) @@ -86,61 +89,64 @@ class ProfilePictureFetcher( return null } - // Decode the downloaded image to a small thumbnail - val thumbnail = decodeAndResize(result) ?: return result + // For source results, read bytes, decode for immediate display, + // and generate thumbnail in background for next time + if (result is SourceFetchResult) { + val bytes = result.source.source().use { it.readByteArray() } - // Save thumbnail to our cache - thumbnailCache.save(originalUrl, thumbnail) + // Decode at thumbnail size for immediate display + val bitmap = decodeThumbnailFromBytes(bytes) + if (bitmap != null) { + // Fire-and-forget: save thumbnail to disk for next load + backgroundScope.launch { + try { + thumbnailCache.save(originalUrl, bitmap) + } catch (_: Exception) { + // Best-effort + } + } - return ImageFetchResult( - image = thumbnail.asImage(true), - isSampled = true, - dataSource = DataSource.NETWORK, - ) + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = true, + dataSource = DataSource.NETWORK, + ) + } + } + + return result } - private fun decodeAndResize(result: FetchResult): Bitmap? = + private fun decodeThumbnailFromBytes(bytes: ByteArray): Bitmap? = try { - when (result) { - is SourceFetchResult -> { - val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX + val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX - // First pass: decode bounds only - val boundsOptions = - BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - val source = result.source - val bytes = source.source().use { it.readByteArray() } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) - - // Calculate inSampleSize for efficient decode - val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) - - // Second pass: decode at reduced size - val decodeOptions = - BitmapFactory.Options().apply { - inSampleSize = sampleSize - } - val decoded = - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) - ?: return null - - // Scale to exact target size - val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) - if (scaled !== decoded) { - decoded.recycle() - } - scaled + // First pass: decode bounds only + val boundsOptions = + BitmapFactory.Options().apply { + inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) - is ImageFetchResult -> { - // Already decoded (e.g. from Base64Fetcher) — not expected for profile pics - null + // Calculate inSampleSize for efficient memory use during decode + val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) + + // Second pass: decode at reduced size + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sampleSize } + val decoded = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) + ?: return null + + // Scale to exact target size + val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) + if (scaled !== decoded) { + decoded.recycle() } + scaled } catch (e: Exception) { - if (e is CancellationException) throw e null } @@ -175,6 +181,7 @@ class ProfilePictureFetcher( class Factory( private val thumbnailCache: ThumbnailDiskCache, private val networkClient: (url: String) -> Call.Factory, + private val backgroundScope: CoroutineScope, ) : Fetcher.Factory { private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) @@ -198,7 +205,7 @@ class ProfilePictureFetcher( concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, ) - return ProfilePictureFetcher(originalUrl, options, thumbnailCache, netFetcher) + return ProfilePictureFetcher(originalUrl, options, thumbnailCache, netFetcher, backgroundScope) } } From 6c9f6531cfa55b6bd7c844ef2403fa0c694343c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:07:17 +0000 Subject: [PATCH 3/6] fix: prevent race conditions in thumbnail disk cache - Atomic writes: write to temp file then rename, so readers never see a partially written JPEG - Deduplication: ConcurrentHashMap tracks in-flight URLs so multiple composables requesting the same profile picture don't trigger duplicate decode+save work https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../service/images/ProfilePictureFetcher.kt | 6 +-- .../service/images/ThumbnailDiskCache.kt | 46 +++++++++++++++---- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 9d0aa638b..0d8054399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -99,11 +99,7 @@ class ProfilePictureFetcher( if (bitmap != null) { // Fire-and-forget: save thumbnail to disk for next load backgroundScope.launch { - try { - thumbnailCache.save(originalUrl, bitmap) - } catch (_: Exception) { - // Best-effort - } + thumbnailCache.save(originalUrl, bitmap) } return ImageFetchResult( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 8cb2a79e0..e1f7f8f30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -25,6 +25,7 @@ import android.graphics.BitmapFactory import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File +import java.util.concurrent.ConcurrentHashMap /** * Disk cache for pre-resized profile picture thumbnails. @@ -33,8 +34,10 @@ import java.io.File * This avoids re-decoding large original images from Coil's disk cache on * every recomposition when images are evicted from the memory cache. * - * With 60 profile pictures on screen, reading ~300-600KB of tiny thumbnails - * is dramatically faster than re-decoding 60 full-size originals. + * Thread safety: + * - Writes use atomic temp-file-then-rename to prevent partial reads. + * - [inFlight] tracks URLs currently being saved to prevent duplicate work + * when multiple composables request the same profile picture simultaneously. */ class ThumbnailDiskCache( private val cacheDir: File, @@ -45,6 +48,9 @@ class ThumbnailDiskCache( const val JPEG_QUALITY = 80 } + // Tracks URLs currently being written to prevent duplicate saves + private val inFlight = ConcurrentHashMap.newKeySet() + init { cacheDir.mkdirs() } @@ -56,16 +62,40 @@ class ThumbnailDiskCache( return if (file.exists()) file else null } + /** + * Saves a bitmap as a JPEG thumbnail. Uses atomic write (temp file + rename) + * to prevent readers from seeing a partially written file. + * + * Returns true if saved, false if already in flight or on error. + */ fun save( url: String, bitmap: Bitmap, - ): File { - val file = File(cacheDir, keyFor(url)) - file.outputStream().use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + ): Boolean { + // Skip if another coroutine is already saving this URL + if (!inFlight.add(url)) return false + + try { + val key = keyFor(url) + val finalFile = File(cacheDir, key) + + // Already saved by another coroutine that finished before us + if (finalFile.exists()) return true + + // Write to temp file, then atomically rename + val tempFile = File(cacheDir, "$key.tmp") + tempFile.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + } + tempFile.renameTo(finalFile) + + evictIfNeeded() + return true + } catch (e: Exception) { + return false + } finally { + inFlight.remove(url) } - evictIfNeeded() - return file } fun decodeThumbnail(file: File): Bitmap? = From 3f032710d2ec303f3e0352ff6d32c84a1d30c945 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:14:48 +0000 Subject: [PATCH 4/6] perf: avoid byte array allocation on first profile picture load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On cache miss, instead of reading the entire downloaded image into a byte array (which could be 50MB+ for a malicious profile picture), the fetcher now passes the NetworkFetcher result straight through to Coil's normal streaming decoder pipeline — zero overhead vs current. Thumbnail generation runs in background from Coil's disk cache file using file-based BitmapFactory.decodeFile(), which is seekable (supports two-pass bounds+decode) and never buffers the full file. https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../service/images/ProfilePictureFetcher.kt | 87 ++++++++++--------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 0d8054399..340906c5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -28,10 +28,10 @@ import coil3.Uri import coil3.annotation.ExperimentalCoilApi import coil3.asImage import coil3.decode.DataSource +import coil3.disk.DiskCache import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult -import coil3.fetch.SourceFetchResult import coil3.key.Keyer import coil3.network.CacheStrategy import coil3.network.ConcurrentRequestStrategy @@ -49,11 +49,12 @@ import kotlin.coroutines.cancellation.CancellationException * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. * * URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. - * This fetcher: - * 1. Checks the thumbnail disk cache for a pre-resized JPEG (~5-10KB) - * 2. On hit: returns the tiny thumbnail directly (fast disk read) - * 3. On miss: returns the network result immediately for display, and generates - * the thumbnail in the background for next time + * + * Flow: + * - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode. + * - Thumbnail cache miss (first load): passes the result straight through to Coil's normal + * pipeline (zero overhead). In the background, reads the file from Coil's disk cache + * after download and generates a thumbnail for next time. * * The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), * which goes through the normal Coil pipeline for full-resolution display. @@ -64,6 +65,7 @@ class ProfilePictureFetcher( private val options: Options, private val thumbnailCache: ThumbnailDiskCache, private val networkFetcher: Fetcher, + private val diskCacheLazy: Lazy, private val backgroundScope: CoroutineScope, ) : Fetcher { override suspend fun fetch(): FetchResult? { @@ -80,7 +82,11 @@ class ProfilePictureFetcher( } } - // Cache miss: download via normal network fetcher + // Cache miss: download via normal network fetcher. + // NetworkFetcher writes the original to Coil's disk cache and returns + // a source pointing to it. We pass this through unchanged — Coil's + // decoder pipeline handles streaming decode with inSampleSize, no + // large byte array allocation. val result = try { networkFetcher.fetch() ?: return null @@ -89,62 +95,57 @@ class ProfilePictureFetcher( return null } - // For source results, read bytes, decode for immediate display, - // and generate thumbnail in background for next time - if (result is SourceFetchResult) { - val bytes = result.source.source().use { it.readByteArray() } - - // Decode at thumbnail size for immediate display - val bitmap = decodeThumbnailFromBytes(bytes) - if (bitmap != null) { - // Fire-and-forget: save thumbnail to disk for next load - backgroundScope.launch { - thumbnailCache.save(originalUrl, bitmap) - } - - return ImageFetchResult( - image = bitmap.asImage(true), - isSampled = true, - dataSource = DataSource.NETWORK, - ) - } + // Fire-and-forget: generate thumbnail from Coil's disk cache in background. + // The file is already there because NetworkFetcher just wrote it. + backgroundScope.launch { + generateThumbnailFromDiskCache() } return result } - private fun decodeThumbnailFromBytes(bytes: ByteArray): Bitmap? = - try { + /** + * Reads the original image from Coil's disk cache and generates a small + * thumbnail for our dedicated thumbnail cache. Uses file-based BitmapFactory + * decode which is seekable (supports two-pass bounds+decode) and never loads + * the entire file into a byte array. + */ + private fun generateThumbnailFromDiskCache() { + val diskCache = diskCacheLazy.value ?: return + + diskCache.openSnapshot(originalUrl)?.use { snapshot -> + val file = snapshot.data.toFile() val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX - // First pass: decode bounds only + // First pass: decode bounds only (no memory allocation for pixels) val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) + BitmapFactory.decodeFile(file.absolutePath, boundsOptions) + + if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) return // Calculate inSampleSize for efficient memory use during decode val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) - // Second pass: decode at reduced size + // Second pass: decode at reduced size (streams from file, not byte array) val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } - val decoded = - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) - ?: return null + val decoded = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return // Scale to exact target size val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) if (scaled !== decoded) { decoded.recycle() } - scaled - } catch (e: Exception) { - null + + thumbnailCache.save(originalUrl, scaled) + scaled.recycle() } + } private fun calculateInSampleSize( options: BitmapFactory.Options, @@ -189,19 +190,27 @@ class ProfilePictureFetcher( if (data.scheme != SCHEME) return null val originalUrl = extractOriginalUrl(data) + val diskCacheLazy = lazy { imageLoader.diskCache } val netFetcher = NetworkFetcher( url = originalUrl, options = options, networkClient = lazy { networkClient(originalUrl).asNetworkClient() }, - diskCache = lazy { imageLoader.diskCache }, + diskCache = diskCacheLazy, cacheStrategy = lazy { CacheStrategy.DEFAULT }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, ) - return ProfilePictureFetcher(originalUrl, options, thumbnailCache, netFetcher, backgroundScope) + return ProfilePictureFetcher( + originalUrl, + options, + thumbnailCache, + netFetcher, + diskCacheLazy, + backgroundScope, + ) } } From 0df4c5e19c134a7f380b1ef360fdec7377aa344e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:22:38 +0000 Subject: [PATCH 5/6] refactor: use typed ProfilePictureUrl class instead of URI scheme string Replace profilepic:// string scheme with a ProfilePictureUrl data class. Coil routes to the fetcher by type via Fetcher.Factory instead of parsing a URI scheme string on every load. This avoids string concatenation at the call site and string parsing in the fetcher/keyer. https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../service/images/ProfilePictureFetcher.kt | 44 ++++++------------- .../ui/components/RobohashAsyncImage.kt | 4 +- .../commons/ui/components/UserAvatar.kt | 14 ++++-- 3 files changed, 26 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 340906c5d..cc7c4c3d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -24,7 +24,6 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.compose.runtime.Stable import coil3.ImageLoader -import coil3.Uri import coil3.annotation.ExperimentalCoilApi import coil3.asImage import coil3.decode.DataSource @@ -39,7 +38,7 @@ import coil3.network.ConnectivityChecker import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.Options -import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME +import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import okhttp3.Call @@ -48,7 +47,8 @@ import kotlin.coroutines.cancellation.CancellationException /** * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. * - * URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. + * Composables pass [ProfilePictureUrl] as the model to AsyncImage. Coil routes to this + * fetcher by type — no string concatenation or scheme parsing needed. * * Flow: * - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode. @@ -56,7 +56,7 @@ import kotlin.coroutines.cancellation.CancellationException * pipeline (zero overhead). In the background, reads the file from Coil's disk cache * after download and generates a thumbnail for next time. * - * The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), + * The zoomable full-screen dialog uses the raw URL string (not wrapped in ProfilePictureUrl), * which goes through the normal Coil pipeline for full-resolution display. */ @Stable @@ -164,39 +164,26 @@ class ProfilePictureFetcher( return inSampleSize } - companion object { - const val SCHEME = PROFILE_PIC_SCHEME - - fun extractOriginalUrl(data: Uri): String { - // profilepic://https://example.com/pic.jpg → https://example.com/pic.jpg - val full = data.toString() - return full.removePrefix("$SCHEME://") - } - } - @OptIn(ExperimentalCoilApi::class) class Factory( private val thumbnailCache: ThumbnailDiskCache, private val networkClient: (url: String) -> Call.Factory, private val backgroundScope: CoroutineScope, - ) : Fetcher.Factory { + ) : Fetcher.Factory { private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) override fun create( - data: Uri, + data: ProfilePictureUrl, options: Options, imageLoader: ImageLoader, - ): Fetcher? { - if (data.scheme != SCHEME) return null - - val originalUrl = extractOriginalUrl(data) + ): Fetcher { val diskCacheLazy = lazy { imageLoader.diskCache } val netFetcher = NetworkFetcher( - url = originalUrl, + url = data.url, options = options, - networkClient = lazy { networkClient(originalUrl).asNetworkClient() }, + networkClient = lazy { networkClient(data.url).asNetworkClient() }, diskCache = diskCacheLazy, cacheStrategy = lazy { CacheStrategy.DEFAULT }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, @@ -204,7 +191,7 @@ class ProfilePictureFetcher( ) return ProfilePictureFetcher( - originalUrl, + data.url, options, thumbnailCache, netFetcher, @@ -214,15 +201,10 @@ class ProfilePictureFetcher( } } - object BKeyer : Keyer { + object BKeyer : Keyer { override fun key( - data: Uri, + data: ProfilePictureUrl, options: Options, - ): String? = - if (data.scheme == SCHEME) { - "profilepic_thumb_${extractOriginalUrl(data)}" - } else { - null - } + ): String = "profilepic_thumb_${data.url}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 39aadfd1e..936429f36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash -import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME +import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter @@ -93,7 +93,7 @@ fun RobohashFallbackAsyncImage( } AsyncImage( - model = "$PROFILE_PIC_SCHEME://$model", + model = ProfilePictureUrl(model), contentDescription = contentDescription, modifier = modifier, placeholder = painter, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index d1fbd9d1f..e48d70b3a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -41,7 +41,15 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.ui.theme.isLight -const val PROFILE_PIC_SCHEME = "profilepic" +/** + * Wrapper class for profile picture URLs that signals Coil to use the thumbnail + * disk cache fetcher instead of the normal network pipeline. Passing this as the + * model to AsyncImage avoids string concatenation/parsing and lets Coil route + * by type via Fetcher.Factory. + */ +data class ProfilePictureUrl( + val url: String, +) /** * Shared avatar component that displays a user's profile picture with Robohash fallback. @@ -73,9 +81,9 @@ fun UserAvatar( .clip(shape = CircleShape) } - val imageModel = + val imageModel: Any? = if (pictureUrl != null && useThumbnailCache) { - "$PROFILE_PIC_SCHEME://$pictureUrl" + ProfilePictureUrl(pictureUrl) } else { pictureUrl } From 361ef6798ea4f9cf4fe8113fec4995f530f3f7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:36:34 +0000 Subject: [PATCH 6/6] refactor: simplify thumbnail cache API and remove unused code - ThumbnailDiskCache: combine get()+decodeThumbnail() into single load() method. Move bitmap resize logic into generateFromFile() so callers just pass a source file and the cache handles decode+resize+save. - ProfilePictureFetcher: remove unused `options` field, remove @Stable annotation, delegate all resize logic to ThumbnailDiskCache. - UserAvatar: fix stale doc comment. https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv --- .../service/images/ProfilePictureFetcher.kt | 117 ++++-------------- .../service/images/ThumbnailDiskCache.kt | 77 +++++++++--- .../commons/ui/components/UserAvatar.kt | 2 +- 3 files changed, 79 insertions(+), 117 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index cc7c4c3d3..0f85b5926 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.service.images -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import androidx.compose.runtime.Stable import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.asImage @@ -45,48 +42,35 @@ import okhttp3.Call import kotlin.coroutines.cancellation.CancellationException /** - * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. + * Coil Fetcher for profile picture thumbnails. * - * Composables pass [ProfilePictureUrl] as the model to AsyncImage. Coil routes to this - * fetcher by type — no string concatenation or scheme parsing needed. + * Composables pass [ProfilePictureUrl] as the AsyncImage model. Coil routes here by type. * - * Flow: - * - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode. - * - Thumbnail cache miss (first load): passes the result straight through to Coil's normal - * pipeline (zero overhead). In the background, reads the file from Coil's disk cache - * after download and generates a thumbnail for next time. + * - Cache hit: returns a tiny ~5KB JPEG from the thumbnail disk cache. + * - Cache miss: passes the network result straight through to Coil (zero overhead), + * then generates the thumbnail in the background from Coil's disk cache for next time. * - * The zoomable full-screen dialog uses the raw URL string (not wrapped in ProfilePictureUrl), - * which goes through the normal Coil pipeline for full-resolution display. + * Full-size display (zoomable dialog) uses the raw URL string, bypassing this fetcher. */ -@Stable class ProfilePictureFetcher( - private val originalUrl: String, - private val options: Options, + private val url: String, private val thumbnailCache: ThumbnailDiskCache, private val networkFetcher: Fetcher, private val diskCacheLazy: Lazy, private val backgroundScope: CoroutineScope, ) : Fetcher { override suspend fun fetch(): FetchResult? { - // Fast path: return pre-resized thumbnail from disk (~5KB read) - val cached = thumbnailCache.get(originalUrl) - if (cached != null) { - val bitmap = thumbnailCache.decodeThumbnail(cached) - if (bitmap != null) { - return ImageFetchResult( - image = bitmap.asImage(true), - isSampled = true, - dataSource = DataSource.DISK, - ) - } + // Fast path: return pre-resized thumbnail (~5KB read + decode) + val bitmap = thumbnailCache.load(url) + if (bitmap != null) { + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = true, + dataSource = DataSource.DISK, + ) } - // Cache miss: download via normal network fetcher. - // NetworkFetcher writes the original to Coil's disk cache and returns - // a source pointing to it. We pass this through unchanged — Coil's - // decoder pipeline handles streaming decode with inSampleSize, no - // large byte array allocation. + // Cache miss: let Coil's normal pipeline handle download + decode val result = try { networkFetcher.fetch() ?: return null @@ -95,75 +79,17 @@ class ProfilePictureFetcher( return null } - // Fire-and-forget: generate thumbnail from Coil's disk cache in background. - // The file is already there because NetworkFetcher just wrote it. + // Generate thumbnail in background from Coil's disk cache for next time backgroundScope.launch { - generateThumbnailFromDiskCache() + val diskCache = diskCacheLazy.value ?: return@launch + diskCache.openSnapshot(url)?.use { snapshot -> + thumbnailCache.generateFromFile(url, snapshot.data.toFile()) + } } return result } - /** - * Reads the original image from Coil's disk cache and generates a small - * thumbnail for our dedicated thumbnail cache. Uses file-based BitmapFactory - * decode which is seekable (supports two-pass bounds+decode) and never loads - * the entire file into a byte array. - */ - private fun generateThumbnailFromDiskCache() { - val diskCache = diskCacheLazy.value ?: return - - diskCache.openSnapshot(originalUrl)?.use { snapshot -> - val file = snapshot.data.toFile() - val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX - - // First pass: decode bounds only (no memory allocation for pixels) - val boundsOptions = - BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.absolutePath, boundsOptions) - - if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) return - - // Calculate inSampleSize for efficient memory use during decode - val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) - - // Second pass: decode at reduced size (streams from file, not byte array) - val decodeOptions = - BitmapFactory.Options().apply { - inSampleSize = sampleSize - } - val decoded = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return - - // Scale to exact target size - val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) - if (scaled !== decoded) { - decoded.recycle() - } - - thumbnailCache.save(originalUrl, scaled) - scaled.recycle() - } - } - - private fun calculateInSampleSize( - options: BitmapFactory.Options, - reqWidth: Int, - reqHeight: Int, - ): Int { - val (height, width) = options.outHeight to options.outWidth - var inSampleSize = 1 - if (height > reqHeight || width > reqWidth) { - val halfHeight = height / 2 - val halfWidth = width / 2 - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - return inSampleSize - } - @OptIn(ExperimentalCoilApi::class) class Factory( private val thumbnailCache: ThumbnailDiskCache, @@ -192,7 +118,6 @@ class ProfilePictureFetcher( return ProfilePictureFetcher( data.url, - options, thumbnailCache, netFetcher, diskCacheLazy, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index e1f7f8f30..9e3d6789c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -45,10 +45,9 @@ class ThumbnailDiskCache( ) { companion object { const val THUMBNAIL_SIZE_PX = 256 - const val JPEG_QUALITY = 80 + private const val JPEG_QUALITY = 80 } - // Tracks URLs currently being written to prevent duplicate saves private val inFlight = ConcurrentHashMap.newKeySet() init { @@ -57,36 +56,66 @@ class ThumbnailDiskCache( private fun keyFor(url: String): String = sha256(url.toByteArray()).toHexKey() - fun get(url: String): File? { + /** + * Loads a cached thumbnail bitmap for the given URL, or null if not cached. + */ + fun load(url: String): Bitmap? { val file = File(cacheDir, keyFor(url)) - return if (file.exists()) file else null + if (!file.exists()) return null + return try { + BitmapFactory.decodeFile(file.absolutePath) + } catch (e: Exception) { + file.delete() + null + } } /** - * Saves a bitmap as a JPEG thumbnail. Uses atomic write (temp file + rename) - * to prevent readers from seeing a partially written file. + * Generates a thumbnail from a source file and saves it to the cache. + * Uses two-pass BitmapFactory decode (bounds then pixels) so the full + * file is never loaded into a byte array. * - * Returns true if saved, false if already in flight or on error. + * Returns true if saved, false if already in flight, already cached, or on error. */ - fun save( + fun generateFromFile( url: String, - bitmap: Bitmap, + sourceFile: File, ): Boolean { - // Skip if another coroutine is already saving this URL if (!inFlight.add(url)) return false try { val key = keyFor(url) val finalFile = File(cacheDir, key) - - // Already saved by another coroutine that finished before us if (finalFile.exists()) return true - // Write to temp file, then atomically rename + val path = sourceFile.absolutePath + val targetSize = THUMBNAIL_SIZE_PX + + // First pass: decode bounds only + val boundsOptions = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(path, boundsOptions) + if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) return false + + // Second pass: decode at reduced size + val sampleSize = calculateInSampleSize(boundsOptions, targetSize) + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + } + val decoded = BitmapFactory.decodeFile(path, decodeOptions) ?: return false + + // Scale to exact target size and write atomically + val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) + if (scaled !== decoded) decoded.recycle() + val tempFile = File(cacheDir, "$key.tmp") tempFile.outputStream().use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) } + scaled.recycle() tempFile.renameTo(finalFile) evictIfNeeded() @@ -98,13 +127,21 @@ class ThumbnailDiskCache( } } - fun decodeThumbnail(file: File): Bitmap? = - try { - BitmapFactory.decodeFile(file.absolutePath) - } catch (e: Exception) { - file.delete() - null + private fun calculateInSampleSize( + options: BitmapFactory.Options, + targetSize: Int, + ): Int { + val (height, width) = options.outHeight to options.outWidth + var inSampleSize = 1 + if (height > targetSize || width > targetSize) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= targetSize && halfWidth / inSampleSize >= targetSize) { + inSampleSize *= 2 + } } + return inSampleSize + } private fun evictIfNeeded() { val files = cacheDir.listFiles() ?: return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index e48d70b3a..bb71afaee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -61,7 +61,7 @@ data class ProfilePictureUrl( * @param contentDescription Accessibility description * @param loadProfilePicture Whether to load the profile picture (false = show robohash only) * @param loadRobohash Whether to generate robohash (false = show generic icon) - * @param useThumbnailCache Whether to wrap the URL with profilepic:// scheme for thumbnail caching + * @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads */ @Composable fun UserAvatar(