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
|
// thumbnail disk cache for profile pictures
|
||||||
val thumbnailDiskCache: ThumbnailDiskCache by lazy {
|
val thumbnailDiskCache: ThumbnailDiskCache by lazy {
|
||||||
Log.d("AppModules", "ThumbnailDiskCache Init")
|
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
|
// crash report storage
|
||||||
|
|||||||
+24
-3
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.images
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
@@ -111,9 +112,29 @@ class ThumbnailDiskCache(
|
|||||||
}
|
}
|
||||||
val decoded = BitmapFactory.decodeFile(path, decodeOptions) ?: return false
|
val decoded = BitmapFactory.decodeFile(path, decodeOptions) ?: return false
|
||||||
|
|
||||||
// Scale to exact target size and write atomically
|
// Fuse center-crop + scale into one allocation; matches CircleShape + ContentScale.Crop.
|
||||||
val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true)
|
val side = minOf(decoded.width, decoded.height)
|
||||||
if (scaled !== decoded) decoded.recycle()
|
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")
|
val tempFile = File(cacheDir, "$key.tmp")
|
||||||
tempFile.outputStream().use { out ->
|
tempFile.outputStream().use { out ->
|
||||||
|
|||||||
Reference in New Issue
Block a user