diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt new file mode 100644 index 0000000000..cd01f55818 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt @@ -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 { + 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 { + // 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 { + // 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 { + 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 + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt new file mode 100644 index 0000000000..4c94193825 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt new file mode 100644 index 0000000000..bc44178bbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt @@ -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() + + 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() + +// 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 53ecdf75f5..8e982bba44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context +import android.os.Build import android.os.Handler import android.os.Looper import android.view.WindowManager @@ -44,6 +45,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -182,20 +184,29 @@ fun ZoomableImageDialog( val activityWindow = getActivityWindow() val dialogWindow = getDialogWindow() - if (activityWindow != null && dialogWindow != null) { - // Preserve any brightness override already applied to the dialog window (e.g. by the - // fullscreen swipe controls). This block re-runs on recomposition (orientation change - // re-reads `orientation` above), and copying the activity attributes would otherwise - // reset screenBrightness and snap the user's brightness back mid-session. - val currentBrightness = dialogWindow.attributes.screenBrightness - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - // Disable the system dim so the thumbnail stays visible behind the growing dialog. - attributes.dimAmount = 0f - attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() - attributes.screenBrightness = currentBrightness - dialogWindow.attributes = attributes + // 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) { + // Preserve what the dialog window owns: the brightness override applied by the + // fullscreen swipe controls, and the HDR mode [RequestHdrFor] manages for the + // image on screen. Copying the activity's instead would snap brightness back + // mid-session and cap or force HDR by whatever the feed behind happens to show. + val current = dialogWindow.attributes + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = current.type + // Disable the system dim so the thumbnail stays visible behind the growing dialog. + attributes.dimAmount = 0f + attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() + 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 + } + onDispose {} } // Go fully immersive while the full-screen media viewer is open. Applies to full-screen @@ -215,9 +226,9 @@ fun ZoomableImageDialog( allImages = allImages, imageUrl = imageUrl, sourceBounds = sourceBounds, - imageBounds = imageBounds, + imageBounds = { imageBounds }, onImageBoundsChanged = updateImageBounds, - currentZoomState = currentZoomState, + currentZoomState = { currentZoomState }, onZoomStateChanged = { currentZoomState = it }, progress = progressProvider, onDismiss = dismissWithAnimation, @@ -232,9 +243,9 @@ private fun DialogContent( allImages: ImmutableList, imageUrl: BaseMediaContent, sourceBounds: Rect?, - imageBounds: Rect?, + imageBounds: () -> Rect?, onImageBoundsChanged: (Rect) -> Unit, - currentZoomState: ZoomState?, + currentZoomState: () -> ZoomState?, onZoomStateChanged: (ZoomState) -> Unit, progress: () -> Float, onDismiss: () -> Unit, @@ -271,12 +282,12 @@ private fun DialogContent( .fillMaxSize() .graphicsLayer { val src = sourceBounds - val img = imageBounds + val img = imageBounds() if (src != null && img != null && src.hasArea() && img.hasArea()) { // Account for user-applied zoom: the exit animation must start from // the visible bounds, not the unzoomed layout bounds — otherwise // 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 // the grow animation. The image covers the source rect; the overflow // is clipped below. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 4d70f3f355..40c99907e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -457,9 +457,11 @@ fun LocalImageView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(loadedImageModifier) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, fullscreen = fullResolution) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height) + MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height) } content.isVerified?.let { @@ -599,9 +601,11 @@ fun UrlImageView( ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, fullscreen = fullResolution) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.url, drawable.width, drawable.height) + MediaAspectRatioCache.add(content.url, image.width, image.height) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt new file mode 100644 index 0000000000..bc594b173d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt @@ -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) + } +}