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) <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-25 12:15:22 +02:00
parent 82bbfa3d94
commit 550bf1dfef
2 changed files with 27 additions and 4 deletions
@@ -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
@@ -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 ->