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,