From 550bf1dfef9134c983a0ae075c4867fc497dea2e Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 25 Apr 2026 12:15:22 +0200 Subject: [PATCH] fix(avatar): center-crop profile picture thumbnails to prevent squashing The profile picture thumbnail cache pre-resized every source to a square 256x256 via createScaledBitmap, stretching non-square avatars. The cache hit path returned the pre-squashed bitmap with isSampled=true, so the ContentScale.Crop at the composable could not undo it. Fuses center-crop + scale into a single Bitmap.createBitmap call with a scale matrix so the cached thumbnail matches the CircleShape + ContentScale.Crop render contract with one allocation. try/finally guarantees the source bitmap is recycled even if createBitmap throws. Bumps the cache subdir to profile_thumbnails_v2 so existing squashed thumbnails are abandoned and regenerated, and reclaims the orphaned v1 directory on first init. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 ++- .../service/images/ThumbnailDiskCache.kt | 27 ++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 06f0066bc..75d98fe7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -440,7 +440,9 @@ class AppModules( // thumbnail disk cache for profile pictures val thumbnailDiskCache: ThumbnailDiskCache by lazy { Log.d("AppModules", "ThumbnailDiskCache Init") - ThumbnailDiskCache(appContext.safeCacheDir().resolve("profile_thumbnails")) + // One-shot reclaim of the v1 cache dir, which held squashed thumbnails. + appContext.safeCacheDir().resolve("profile_thumbnails").deleteRecursively() + ThumbnailDiskCache(appContext.safeCacheDir().resolve("profile_thumbnails_v2")) } // crash report storage 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 cdbf276a7..d10d3ebe2 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.images import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Matrix import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -111,9 +112,29 @@ class ThumbnailDiskCache( } 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() + // Fuse center-crop + scale into one allocation; matches CircleShape + ContentScale.Crop. + val side = minOf(decoded.width, decoded.height) + val scaled = + if (decoded.width == targetSize && decoded.height == targetSize) { + decoded + } else { + // try/finally ensures decoded is recycled even if createBitmap throws. + try { + val scale = targetSize.toFloat() / side + val matrix = Matrix().apply { setScale(scale, scale) } + Bitmap.createBitmap( + decoded, + (decoded.width - side) / 2, + (decoded.height - side) / 2, + side, + side, + matrix, + true, + ) + } finally { + decoded.recycle() + } + } val tempFile = File(cacheDir, "$key.tmp") tempFile.outputStream().use { out ->