Merge pull request #4284 from davotoula/feat/hdr-photos

feat: display Ultra HDR photos in HDR
This commit is contained in:
Vitor Pamplona
2026-09-30 07:50:43 -04:00
committed by GitHub
6 changed files with 623 additions and 24 deletions
@@ -0,0 +1,257 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Gainmap
import android.os.Build
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.BitmapImage
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.decode.DataSource
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Precision
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.net.InetAddress
import java.net.ServerSocket
import java.util.UUID
import kotlin.concurrent.thread
/**
* Walks an Ultra HDR JPEG (a JPEG with an ISO 21496-1 / Android gain map) through every stage an
* uploaded or viewed photo passes, asserting the gain map survives each one. Losing it anywhere
* turns the photo into plain SDR, which is what "HDR photos don't work" looks like to a user.
*/
@RunWith(AndroidJUnit4::class)
class HdrGainmapPipelineInstrumentedTest {
private lateinit var hdrJpeg: File
@Before
fun setUp() {
assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
hdrJpeg = File(appContext.cacheDir.also { it.mkdirs() }, "${UUID.randomUUID()}.jpg")
val fixture = ultraHdrBitmap()
try {
hdrJpeg.outputStream().use { fixture.compress(Bitmap.CompressFormat.JPEG, 95, it) }
} finally {
fixture.recycleWithGainmap()
}
}
@After
fun tearDown() {
if (::hdrJpeg.isInitialized) hdrJpeg.delete()
}
@Test
fun theFixtureIsAnUltraHdrJpeg() {
assertTrue("fixture lost its gain map", decodesWithGainmap(hdrJpeg))
}
@Test
fun metadataStrippingKeepsTheGainmap() {
val stripped = MetadataStripper.stripImageMetadata(hdrJpeg.toUri(), appContext)
assertTrue("stripper did not run", stripped.stripped)
assertTrue("stripping dropped the gain map", decodesWithGainmap(File(requireNotNull(stripped.uri.path))))
}
@Test
fun compressionKeepsTheGainmap() =
runBlocking<Unit> {
val out = compress(hdrJpeg)
val bounds = bounds(out)
// 4000x3000 against the compressor's 640x816 target: BitmapFactory subsamples by 2.
assertEquals("the compressor did not downscale", 2000, bounds.outWidth)
assertTrue("compression dropped the gain map", decodesWithGainmap(out))
}
@Test
fun compressionOfARotatedPhotoKeepsTheGainmap() =
runBlocking<Unit> {
// Portrait phone photos are stored landscape plus an EXIF rotation, which the
// compressor bakes in through Bitmap.createBitmap(..., matrix, ...).
ExifInterface(hdrJpeg).apply {
setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString())
saveAttributes()
}
val out = compress(hdrJpeg)
val bounds = bounds(out)
assertTrue("the compressor did not rotate", bounds.outHeight > bounds.outWidth)
assertTrue("rotating dropped the gain map", decodesWithGainmap(out))
}
@Test
fun coilDecodeKeepsTheGainmap() =
runBlocking<Unit> {
// Coil owns the bitmaps it returns (its memory cache may hand them out again), so
// release them by shutting the loader down rather than recycling them here.
val loader = ImageLoader.Builder(appContext).build()
try {
for (hardware in listOf(true, false)) {
val request =
ImageRequest
.Builder(appContext)
.data(hdrJpeg)
.size(400, 300)
.allowHardware(hardware)
.build()
val result = loader.execute(request) as SuccessResult
val bitmap = (result.image as BitmapImage).bitmap
assertTrue("Coil dropped the gain map (allowHardware=$hardware)", bitmap.hasGainmap())
}
} finally {
loader.shutdown()
}
}
/**
* A feed image as the app loads it: over HTTP through [OkHttpFactory] (which re-homes the
* disk-cache file so the platform ImageDecoder is reachable), then again from the memory
* cache, then from the disk cache after the memory cache is gone.
*/
@OptIn(ExperimentalCoilApi::class)
@Test
fun coilCachesKeepTheGainmap() =
runBlocking<Unit> {
val cacheDir = File(appContext.cacheDir, "hdr-coil-${UUID.randomUUID()}")
val bytes = hdrJpeg.readBytes()
ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")).use { server ->
serveForever(server, bytes)
val memoryCache = MemoryCache.Builder().maxSizeBytes(64L * 1024 * 1024).build()
val loader =
ImageLoader
.Builder(appContext)
.memoryCache(memoryCache)
.diskCache(DiskCache.Builder().directory(cacheDir.toOkioPath()).build())
.precision(Precision.INEXACT)
.components {
val client = OkHttpClient()
add(OkHttpFactory({ client }, DeDupeConcurrentRequestStrategy()))
}.build()
val url = "http://127.0.0.1:${server.localPort}/hdr.jpg"
val request =
ImageRequest
.Builder(appContext)
.data(url)
.size(400, 300)
.build()
suspend fun load(expected: DataSource) {
val result = loader.execute(request)
if (result is ErrorResult) throw AssertionError("$expected load failed", result.throwable)
result as SuccessResult
assertEquals(expected, result.dataSource)
assertTrue("gain map lost on a $expected load", (result.image as BitmapImage).bitmap.hasGainmap())
}
try {
load(DataSource.NETWORK)
load(DataSource.MEMORY_CACHE)
memoryCache.clear()
load(DataSource.DISK)
} finally {
loader.shutdown()
}
}
cacheDir.deleteRecursively()
}
private suspend fun compress(file: File): File {
val compressed = MediaCompressor().compress(file.toUri(), "image/jpeg", CompressorQuality.MEDIUM, appContext)
assertEquals("the compressor fell back to the original", "image/jpeg", compressed.contentType)
val out = File(requireNotNull(compressed.uri.path))
assertTrue("the compressor returned the original file", out.absolutePath != file.absolutePath)
return out
}
private fun bounds(file: File) = BitmapFactory.Options().apply { inJustDecodeBounds = true }.also { BitmapFactory.decodeFile(file.absolutePath, it) }
// Full-size decodes of a 4000x3000 photo: recycle them rather than wait on the GC between tests.
private fun decodesWithGainmap(file: File): Boolean {
val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return false
return try {
bitmap.hasGainmap()
} finally {
bitmap.recycleWithGainmap()
}
}
// The gain map is a separate bitmap; recycling the base does not free it.
private fun Bitmap.recycleWithGainmap() {
gainmap?.gainmapContents?.recycle()
recycle()
}
private fun serveForever(
server: ServerSocket,
body: ByteArray,
) = thread(isDaemon = true) {
while (!server.isClosed) {
val socket = runCatching { server.accept() }.getOrNull() ?: break
socket.use {
val input = it.getInputStream().bufferedReader()
while (input.readLine()?.isNotEmpty() == true) Unit
it.getOutputStream().apply {
write("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray())
write(body)
flush()
}
}
}
}
private fun ultraHdrBitmap(): Bitmap {
val base = createBitmap(4000, 3000).apply { eraseColor(Color.rgb(120, 140, 160)) }
val contents = createBitmap(1000, 750).apply { eraseColor(Color.rgb(200, 200, 200)) }
base.gainmap =
Gainmap(contents).apply {
setRatioMax(4f, 4f, 4f)
setDisplayRatioForFullHdr(4f)
}
return base
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.pm.ActivityInfo
import android.graphics.Gainmap
import android.os.Build
import android.view.Window
import android.view.WindowManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.asImage
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HdrWindowModeTest {
@get:Rule
val rule = createComposeRule()
@Before
fun requireGainmaps() {
assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
}
private fun hdrImage() = createBitmap(8, 8).apply { gainmap = Gainmap(createBitmap(2, 2)) }.asImage()
private fun sdrImage() = createBitmap(8, 8).asImage()
@Test
fun theWindowIsHdrOnlyWhileAGainmapImageIsShown() {
var window: Window? = null
var showFirst by mutableStateOf(true)
var showSecond by mutableStateOf(true)
val image = hdrImage()
rule.setContent {
window = getActivityWindow()
if (showFirst) RequestHdrFor(image, fullscreen = false)
if (showSecond) RequestHdrFor(image, fullscreen = false)
}
rule.waitForIdle()
assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
showFirst = false
rule.waitForIdle()
assertEquals("one image still wants HDR", ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
showSecond = false
rule.waitForIdle()
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode)
}
@Test
fun anSdrImageLeavesTheWindowAlone() {
var window: Window? = null
rule.setContent {
window = getActivityWindow()
RequestHdrFor(sdrImage(), fullscreen = false)
}
rule.waitForIdle()
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode)
}
@Test
fun insideADialogTheDialogWindowGoesHdr() {
var activityWindow: Window? = null
var dialogWindow: Window? = null
val image = hdrImage()
rule.setContent {
activityWindow = getActivityWindow()
Dialog(onDismissRequest = {}) {
dialogWindow = getDialogWindow()
RequestHdrFor(image, fullscreen = true)
}
}
rule.waitForIdle()
assertEquals(ActivityInfo.COLOR_MODE_HDR, dialogWindow!!.colorMode)
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, activityWindow!!.colorMode)
}
@Test
fun moreHdrImagesDoNotResendTheWindowAttributes() {
var window: Window? = null
var count by mutableStateOf(1)
val image = hdrImage()
var attributeChanges = 0
rule.setContent {
window = getActivityWindow()
repeat(count) { RequestHdrFor(image, fullscreen = false) }
}
rule.waitForIdle()
rule.runOnUiThread {
val original = window!!.callback
window!!.callback =
object : Window.Callback by original {
override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) {
attributeChanges++
original.onWindowAttributesChanged(attrs)
}
}
}
// Cards scrolling in and out while HDR is already on: nothing for the window to change.
count = 4
rule.waitForIdle()
count = 2
rule.waitForIdle()
assertEquals(0, attributeChanges)
assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.pm.ActivityInfo
import android.os.Build
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import coil3.BitmapImage
import coil3.Image
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import java.util.WeakHashMap
/**
* The images on one window that currently want HDR, and how much headroom each asks for.
*
* An Ultra HDR photo (a JPEG with a gain map) only renders brighter than SDR white while its
* window is in [ActivityInfo.COLOR_MODE_HDR]; otherwise the platform silently draws the SDR base
* image. The window is shared by every card in a feed, so HDR stays on until the last image
* asking for it leaves composition, and then the window gets its [originalColorMode] back.
*/
class HdrRequests(
val originalColorMode: Int,
) {
private val headrooms = mutableListOf<Float>()
val wantsHdr: Boolean get() = headrooms.isNotEmpty()
/** [UNCAPPED] if any request is uncapped or there are none, else the largest cap asked for. */
val headroom: Float get() = if (UNCAPPED in headrooms) UNCAPPED else headrooms.maxOrNull() ?: UNCAPPED
fun add(headroom: Float) {
headrooms.add(headroom)
}
/** Drops one request for [headroom]; equal requests are interchangeable. */
fun remove(headroom: Float) {
headrooms.remove(headroom)
}
companion object {
/** `Window.setDesiredHdrHeadroom`'s "no preference": the display's full HDR range. */
const val UNCAPPED = 0f
/**
* Feed cards share the screen with SDR text and chrome, which full HDR brightness makes
* look dim and grey. Cap them; the fullscreen viewer asks for [UNCAPPED].
*/
const val FEED_HEADROOM = 2f
}
}
// Main-thread only: composition and disposal both run there.
private val windowRequests = WeakHashMap<Window, HdrRequests>()
// Each setter dispatches the window attributes to the window manager even when the value is
// unchanged, and cards scroll in and out of a feed constantly: only write what actually changed.
private fun Window.applyHdr(requests: HdrRequests) {
val mode = if (requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else requests.originalColorMode
if (colorMode != mode) colorMode = mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
if (desiredHdrHeadroom != requests.headroom) desiredHdrHeadroom = requests.headroom
}
}
fun Image.hasGainmap(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && (this as? BitmapImage)?.bitmap?.hasGainmap() == true
/**
* Puts the hosting window (the dialog's own window inside a `Dialog`) into HDR mode while this
* is in composition and [image] carries a gain map, so an Ultra HDR photo shows its highlights.
* A [fullscreen] image gets the display's full HDR range; a feed card is capped.
*/
@Composable
fun RequestHdrFor(
image: Image,
fullscreen: Boolean,
) {
if (!remember(image) { image.hasGainmap() }) return
val window = getDialogWindow() ?: getActivityWindow() ?: return
val headroom = if (fullscreen) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM
DisposableEffect(window, headroom) {
val requests = windowRequests.getOrPut(window) { HdrRequests(window.colorMode) }
requests.add(headroom)
window.applyHdr(requests)
onDispose {
requests.remove(headroom)
window.applyHdr(requests)
if (!requests.wantsHdr) windowRequests.remove(window)
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.WindowManager import android.view.WindowManager
@@ -44,6 +45,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -182,21 +184,30 @@ fun ZoomableImageDialog(
val activityWindow = getActivityWindow() val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow() val dialogWindow = getDialogWindow()
// Keyed, not inline: each assignment below is a window-manager round trip, so it must run
// on orientation changes only, not on every recomposition of this content.
DisposableEffect(orientation, activityWindow, dialogWindow) {
if (activityWindow != null && dialogWindow != null) { if (activityWindow != null && dialogWindow != null) {
// Preserve any brightness override already applied to the dialog window (e.g. by the // Preserve what the dialog window owns: the brightness override applied by the
// fullscreen swipe controls). This block re-runs on recomposition (orientation change // fullscreen swipe controls, and the HDR mode [RequestHdrFor] manages for the
// re-reads `orientation` above), and copying the activity attributes would otherwise // image on screen. Copying the activity's instead would snap brightness back
// reset screenBrightness and snap the user's brightness back mid-session. // mid-session and cap or force HDR by whatever the feed behind happens to show.
val currentBrightness = dialogWindow.attributes.screenBrightness val current = dialogWindow.attributes
val attributes = WindowManager.LayoutParams() val attributes = WindowManager.LayoutParams()
attributes.copyFrom(activityWindow.attributes) attributes.copyFrom(activityWindow.attributes)
attributes.type = dialogWindow.attributes.type attributes.type = current.type
// Disable the system dim so the thumbnail stays visible behind the growing dialog. // Disable the system dim so the thumbnail stays visible behind the growing dialog.
attributes.dimAmount = 0f attributes.dimAmount = 0f
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
attributes.screenBrightness = currentBrightness attributes.screenBrightness = current.screenBrightness
attributes.colorMode = current.colorMode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
attributes.desiredHdrHeadroom = current.desiredHdrHeadroom
}
dialogWindow.attributes = attributes dialogWindow.attributes = attributes
} }
onDispose {}
}
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen // Go fully immersive while the full-screen media viewer is open. Applies to full-screen
// images and video alike (shared dialog), and matches the PDF viewer. // images and video alike (shared dialog), and matches the PDF viewer.
@@ -215,9 +226,9 @@ fun ZoomableImageDialog(
allImages = allImages, allImages = allImages,
imageUrl = imageUrl, imageUrl = imageUrl,
sourceBounds = sourceBounds, sourceBounds = sourceBounds,
imageBounds = imageBounds, imageBounds = { imageBounds },
onImageBoundsChanged = updateImageBounds, onImageBoundsChanged = updateImageBounds,
currentZoomState = currentZoomState, currentZoomState = { currentZoomState },
onZoomStateChanged = { currentZoomState = it }, onZoomStateChanged = { currentZoomState = it },
progress = progressProvider, progress = progressProvider,
onDismiss = dismissWithAnimation, onDismiss = dismissWithAnimation,
@@ -232,9 +243,9 @@ private fun DialogContent(
allImages: ImmutableList<BaseMediaContent>, allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent, imageUrl: BaseMediaContent,
sourceBounds: Rect?, sourceBounds: Rect?,
imageBounds: Rect?, imageBounds: () -> Rect?,
onImageBoundsChanged: (Rect) -> Unit, onImageBoundsChanged: (Rect) -> Unit,
currentZoomState: ZoomState?, currentZoomState: () -> ZoomState?,
onZoomStateChanged: (ZoomState) -> Unit, onZoomStateChanged: (ZoomState) -> Unit,
progress: () -> Float, progress: () -> Float,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -271,12 +282,12 @@ private fun DialogContent(
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
val src = sourceBounds val src = sourceBounds
val img = imageBounds val img = imageBounds()
if (src != null && img != null && src.hasArea() && img.hasArea()) { if (src != null && img != null && src.hasArea() && img.hasArea()) {
// Account for user-applied zoom: the exit animation must start from // Account for user-applied zoom: the exit animation must start from
// the visible bounds, not the unzoomed layout bounds — otherwise // the visible bounds, not the unzoomed layout bounds — otherwise
// dismissing a zoomed-in image jumps. // dismissing a zoomed-in image jumps.
val zoomed = img.zoomedBy(currentZoomState) val zoomed = img.zoomedBy(currentZoomState())
// Uniform scale so non-square images keep their aspect ratio during // Uniform scale so non-square images keep their aspect ratio during
// the grow animation. The image covers the source rect; the overflow // the grow animation. The image covers the source rect; the overflow
// is clipped below. // is clipped below.
@@ -457,9 +457,11 @@ fun LocalImageView(
is AsyncImagePainter.State.Success -> { is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent(loadedImageModifier) SubcomposeAsyncImageContent(loadedImageModifier)
val image = (state as AsyncImagePainter.State.Success).result.image
RequestHdrFor(image, fullscreen = fullResolution)
SideEffect { SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height)
MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height)
} }
content.isVerified?.let { content.isVerified?.let {
@@ -599,9 +601,11 @@ fun UrlImageView(
ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd))
val image = (state as AsyncImagePainter.State.Success).result.image
RequestHdrFor(image, fullscreen = fullResolution)
SideEffect { SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image MediaAspectRatioCache.add(content.url, image.width, image.height)
MediaAspectRatioCache.add(content.url, drawable.width, drawable.height)
} }
} }
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HdrRequestsTest {
private fun requests() = HdrRequests(originalColorMode = 0)
@Test
fun noRequestsMeansSdr() {
val requests = requests()
assertFalse(requests.wantsHdr)
assertEquals(HdrRequests.UNCAPPED, requests.headroom)
}
@Test
fun hdrStaysOnUntilTheLastRequestLeaves() {
val requests = requests()
requests.add(2f)
requests.add(2f)
requests.remove(2f)
assertTrue(requests.wantsHdr)
requests.remove(2f)
assertFalse(requests.wantsHdr)
}
@Test
fun theLargestCappedHeadroomWins() {
val requests = requests()
requests.add(2f)
requests.add(3f)
assertEquals(3f, requests.headroom)
}
@Test
fun anUncappedRequestLiftsTheCap() {
val requests = requests()
requests.add(2f)
requests.add(HdrRequests.UNCAPPED)
assertEquals(HdrRequests.UNCAPPED, requests.headroom)
requests.remove(HdrRequests.UNCAPPED)
assertEquals(2f, requests.headroom)
}
}