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:
@@ -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
|
||||
|
||||
+24
-3
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user