fix(mobile): decode remote thumbnails at displayed size (#29965)

* fix(mobile): decode remote thumbnails at displayed size

* use nullable decode size and reuse the tapped thumbnail in the viewer

* clean up decode size naming and guards
This commit is contained in:
Santo Shakil 2026-08-10 21:14:23 +06:00 committed by GitHub
parent c52bf9995b
commit 9862e50aab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 386 additions and 71 deletions

View file

@ -33,12 +33,28 @@ data class Request(
val callback: (Result<Map<String, Long>?>) -> Unit
)
/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
if (exactSize) {
val fillScale = max(
target.width.toDouble() / info.size.width,
target.height.toDouble() / info.size.height
)
val scale = min(1.0, fillScale)
if (scale < 1) {
val width = ceil(info.size.width * scale).toInt()
val height = ceil(info.size.height * scale).toInt()
if (width > 0 && height > 0) {
decoder.setTargetSize(width, height)
}
}
} else {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))

View file

@ -5,6 +5,7 @@ import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.util.Size
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
@ -79,6 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String,
requestId: Long,
preferEncoded: Boolean,
width: Long?,
height: Long?,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@ -102,13 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
decodeExecutor.execute {
val res = if (signal.isCanceled) null else try {
val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap()
// The embedded preview a raw decodes to has no orientation, so read the container's.
val orientation = if (isRawMime(contentType)) {
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
} else {
ExifInterface.ORIENTATION_NORMAL
}
val target = when (orientation) {
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_TRANSVERSE,
ExifInterface.ORIENTATION_ROTATE_270 -> Size(height?.toInt() ?: 0, width?.toInt() ?: 0)
else -> Size(width?.toInt() ?: 0, height?.toInt() ?: 0)
}
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
val bitmap = source.decodeBitmap(target, exactSize = true)
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {