diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7fcd4b3e9..e7d99880c 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,8 @@ class AppModules( memoryCache = { memoryCache }, 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 9e8b40a03..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 { @@ -66,6 +67,8 @@ class ImageLoaderSetup { memoryCache: () -> MemoryCache, blossomServerResolver: () -> BlossomServerResolver, callFactory: (url: String) -> Call.Factory, + thumbnailCache: ThumbnailDiskCache, + backgroundScope: CoroutineScope, ) { SingletonImageLoader.setUnsafe( ImageLoader @@ -81,8 +84,10 @@ class ImageLoaderSetup { add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) add(BlossomFetcher.Factory(blossomServerResolver, callFactory)) + add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope)) 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..0f85b5926 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -0,0 +1,135 @@ +/* + * 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 coil3.ImageLoader +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.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.ProfilePictureUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import okhttp3.Call +import kotlin.coroutines.cancellation.CancellationException + +/** + * Coil Fetcher for profile picture thumbnails. + * + * Composables pass [ProfilePictureUrl] as the AsyncImage model. Coil routes here by type. + * + * - 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. + * + * Full-size display (zoomable dialog) uses the raw URL string, bypassing this fetcher. + */ +class ProfilePictureFetcher( + 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 (~5KB read + decode) + val bitmap = thumbnailCache.load(url) + if (bitmap != null) { + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = true, + dataSource = DataSource.DISK, + ) + } + + // Cache miss: let Coil's normal pipeline handle download + decode + val result = + try { + networkFetcher.fetch() ?: return null + } catch (e: Exception) { + if (e is CancellationException) throw e + return null + } + + // Generate thumbnail in background from Coil's disk cache for next time + backgroundScope.launch { + val diskCache = diskCacheLazy.value ?: return@launch + diskCache.openSnapshot(url)?.use { snapshot -> + thumbnailCache.generateFromFile(url, snapshot.data.toFile()) + } + } + + return result + } + + @OptIn(ExperimentalCoilApi::class) + class Factory( + private val thumbnailCache: ThumbnailDiskCache, + private val networkClient: (url: String) -> Call.Factory, + private val backgroundScope: CoroutineScope, + ) : Fetcher.Factory { + private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) + + override fun create( + data: ProfilePictureUrl, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + val diskCacheLazy = lazy { imageLoader.diskCache } + + val netFetcher = + NetworkFetcher( + url = data.url, + options = options, + networkClient = lazy { networkClient(data.url).asNetworkClient() }, + diskCache = diskCacheLazy, + cacheStrategy = lazy { CacheStrategy.DEFAULT }, + connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, + concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + ) + + return ProfilePictureFetcher( + data.url, + thumbnailCache, + netFetcher, + diskCacheLazy, + backgroundScope, + ) + } + } + + object BKeyer : Keyer { + override fun key( + data: ProfilePictureUrl, + options: Options, + ): String = "profilepic_thumb_${data.url}" + } +} 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..9e3d6789c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -0,0 +1,155 @@ +/* + * 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 +import java.util.concurrent.ConcurrentHashMap + +/** + * 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. + * + * 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, + private val maxEntries: Int = 10_000, +) { + companion object { + const val THUMBNAIL_SIZE_PX = 256 + private const val JPEG_QUALITY = 80 + } + + private val inFlight = ConcurrentHashMap.newKeySet() + + init { + cacheDir.mkdirs() + } + + private fun keyFor(url: String): String = sha256(url.toByteArray()).toHexKey() + + /** + * 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)) + if (!file.exists()) return null + return try { + BitmapFactory.decodeFile(file.absolutePath) + } catch (e: Exception) { + file.delete() + null + } + } + + /** + * 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, already cached, or on error. + */ + fun generateFromFile( + url: String, + sourceFile: File, + ): Boolean { + if (!inFlight.add(url)) return false + + try { + val key = keyFor(url) + val finalFile = File(cacheDir, key) + if (finalFile.exists()) return true + + 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 -> + scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + } + scaled.recycle() + tempFile.renameTo(finalFile) + + evictIfNeeded() + return true + } catch (e: Exception) { + return false + } finally { + inFlight.remove(url) + } + } + + 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 + 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..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,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.ProfilePictureUrl import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter @@ -92,7 +93,7 @@ fun RobohashFallbackAsyncImage( } AsyncImage( - model = 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 a80059647..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 @@ -41,6 +41,16 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.ui.theme.isLight +/** + * 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. * @@ -51,6 +61,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 use the thumbnail disk cache for faster repeated loads */ @Composable fun UserAvatar( @@ -61,6 +72,7 @@ fun UserAvatar( contentDescription: String? = null, loadProfilePicture: Boolean = true, loadRobohash: Boolean = true, + useThumbnailCache: Boolean = false, ) { val avatarModifier = remember(size, modifier) { @@ -69,7 +81,14 @@ fun UserAvatar( .clip(shape = CircleShape) } - if (pictureUrl != null && loadProfilePicture) { + val imageModel: Any? = + if (pictureUrl != null && useThumbnailCache) { + ProfilePictureUrl(pictureUrl) + } else { + pictureUrl + } + + if (imageModel != null && loadProfilePicture) { // Show profile picture with robohash/icon as fallback val fallbackPainter = if (loadRobohash) { @@ -83,7 +102,7 @@ fun UserAvatar( } AsyncImage( - model = pictureUrl, + model = imageModel, contentDescription = contentDescription, modifier = avatarModifier, placeholder = fallbackPainter,