From e98c7ba17e438733f14332257ba6caaa1975cb4b Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 30 Sep 2026 11:44:34 +0200 Subject: [PATCH 1/4] feat: display Ultra HDR photos in HDR Ultra HDR JPEGs (gain map photos from Android cameras) already survived upload, compression, EXIF stripping and Coil's caches with their gain map intact, but always rendered as SDR: the platform only applies a gain map while the window is in ActivityInfo.COLOR_MODE_HDR, and nothing ever asked for it. RequestHdrFor puts the hosting window (a Dialog's own window inside the fullscreen viewer) into HDR mode while an image with a gain map is in composition, ref-counted per window so a feed stays HDR until the last HDR card leaves. On API 35+ feed cards cap the headroom at 2x so the SDR UI around them doesn't look dim; the fullscreen viewer asks for the full range. Window attributes are only written when they change, since each write is a window-manager round trip and feed cards come and go constantly. Tests: HdrRequestsTest (JVM), HdrWindowModeTest and HdrGainmapPipelineInstrumentedTest (device, API 34+) pin the window mode behaviour and gain map survival through compression (downscale and EXIF rotation), metadata stripping, and Coil network/memory/disk cache loads. --- .../HdrGainmapPipelineInstrumentedTest.kt | 228 ++++++++++++++++++ .../ui/components/HdrWindowModeTest.kt | 144 +++++++++++ .../amethyst/ui/components/HdrWindowMode.kt | 119 +++++++++ .../ui/components/ZoomableContentView.kt | 12 +- .../amethyst/ui/components/HdrRequestsTest.kt | 77 ++++++ 5 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt 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..3e301ea775 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt @@ -0,0 +1,228 @@ +/* + * 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") + hdrJpeg.outputStream().use { ultraHdrBitmap().compress(Bitmap.CompressFormat.JPEG, 95, it) } + } + + @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 { + val loader = ImageLoader.Builder(appContext).build() + 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()) + } + } + + /** + * 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()) + } + + load(DataSource.NETWORK) + load(DataSource.MEMORY_CACHE) + memoryCache.clear() + load(DataSource.DISK) + } + 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) } + + private fun decodesWithGainmap(file: File): Boolean = BitmapFactory.decodeFile(file.absolutePath)?.hasGainmap() == true + + 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..2f4df413b4 --- /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, HdrRequests.FEED_HEADROOM) + if (showSecond) RequestHdrFor(image, HdrRequests.FEED_HEADROOM) + } + 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(), HdrRequests.FEED_HEADROOM) + } + 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, HdrRequests.UNCAPPED) + } + } + 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, HdrRequests.FEED_HEADROOM) } + } + 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..3457150f38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt @@ -0,0 +1,119 @@ +/* + * 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. + */ +class HdrRequests { + private val tokens = mutableListOf() + + private class Request( + val headroom: Float, + ) + + val wantsHdr: Boolean get() = tokens.isNotEmpty() + + /** [UNCAPPED] if any request is uncapped, else the largest cap asked for. */ + val headroom: Float + get() = if (tokens.any { it.headroom == UNCAPPED }) UNCAPPED else tokens.maxOfOrNull { it.headroom } ?: UNCAPPED + + fun add(headroom: Float): Any = Request(headroom).also { tokens.add(it) } + + fun remove(token: Any) { + tokens.removeAll { it === token } + } + + 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 + } +} + +private class HdrWindowState( + val originalColorMode: Int, +) { + val requests = HdrRequests() +} + +// Main-thread only: composition and disposal both run there. +private val windowStates = 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(state: HdrWindowState) { + val mode = if (state.requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else state.originalColorMode + if (colorMode != mode) colorMode = mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + val headroom = if (state.requests.wantsHdr) state.requests.headroom else HdrRequests.UNCAPPED + if (desiredHdrHeadroom != headroom) desiredHdrHeadroom = 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. + */ +@Composable +fun RequestHdrFor( + image: Image, + headroom: Float, +) { + if (!remember(image) { image.hasGainmap() }) return + val window = getDialogWindow() ?: getActivityWindow() ?: return + + DisposableEffect(window, headroom) { + val state = windowStates.getOrPut(window) { HdrWindowState(window.colorMode) } + val token = state.requests.add(headroom) + window.applyHdr(state) + + onDispose { + state.requests.remove(token) + window.applyHdr(state) + if (!state.requests.wantsHdr) windowStates.remove(window) + } + } +} 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..4bcce185aa 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, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + 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, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + 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..c014b9787d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt @@ -0,0 +1,77 @@ +/* + * 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 { + @Test + fun noRequestsMeansSdr() { + val requests = HdrRequests() + assertFalse(requests.wantsHdr) + } + + @Test + fun hdrStaysOnUntilTheLastRequestLeaves() { + val requests = HdrRequests() + val first = requests.add(2f) + val second = requests.add(2f) + + requests.remove(first) + assertTrue(requests.wantsHdr) + + requests.remove(second) + assertFalse(requests.wantsHdr) + } + + @Test + fun theLargestCappedHeadroomWins() { + val requests = HdrRequests() + requests.add(2f) + requests.add(3f) + assertEquals(3f, requests.headroom) + } + + @Test + fun anUncappedRequestLiftsTheCap() { + val requests = HdrRequests() + requests.add(2f) + val fullscreen = requests.add(HdrRequests.UNCAPPED) + assertEquals(HdrRequests.UNCAPPED, requests.headroom) + + requests.remove(fullscreen) + assertEquals(2f, requests.headroom) + } + + @Test + fun equalHeadroomsAreStillSeparateRequests() { + val requests = HdrRequests() + val first = requests.add(2f) + requests.add(2f) + + requests.remove(first) + requests.remove(first) + assertTrue("removing one token twice must not release the other", requests.wantsHdr) + } +} From bf8e11ae1aaf6986b6b268f4a23f1ee1f800ebb3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 30 Sep 2026 11:44:44 +0200 Subject: [PATCH 2/4] fix: stop the media dialog clobbering its window attributes every frame ZoomableImageDialog copied the activity window's attributes onto the dialog window inline in composition. That content recomposes on every frame of a pager swipe (it reads the image bounds the pager moves), so a swipe sent ~60 attribute updates to the window manager, and each one overwrote the dialog's own colorMode and desiredHdrHeadroom with the feed's. On a Pixel 9a that capped fullscreen HDR photos at the feed's 2x, kept an SDR page in HDR mode (ratio 5), and made swiping back to an HDR photo land in SDR depending on timing. Run the copy in an effect keyed on orientation and the two windows, and preserve the dialog's colorMode and HDR headroom alongside the brightness override it already kept. Measured after: HDR page 5, SDR page 1, HDR again 5, stable across repeated swipes. --- .../ui/components/ZoomableContentDialog.kt | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) 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..7a010cfe21 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: this content recomposes on every frame of a pager swipe (the image + // bounds it reads move), and each assignment below is a window-manager round trip. + 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 From ac5936c1499c8f2aff8f4274f5fe55aeab1337b9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 30 Sep 2026 12:15:32 +0200 Subject: [PATCH 3/4] refactor: simplify HDR request tracking per code review - HdrRequests tracks plain headroom values instead of identity tokens (equal requests are interchangeable) and carries the window's original colorMode, replacing the separate HdrWindowState wrapper. - RequestHdrFor takes `fullscreen` and picks the headroom itself, so the two image views no longer repeat the UNCAPPED/FEED_HEADROOM choice. - ZoomableImageDialog passes imageBounds and the zoom state to DialogContent as lambdas, read only in graphicsLayer. The dialog shell no longer recomposes on every pager swipe frame (was ~60 per swipe, now 0), which was the root cause behind the attribute copy clobbering the dialog's HDR mode. --- .../ui/components/HdrWindowModeTest.kt | 10 ++-- .../amethyst/ui/components/HdrWindowMode.kt | 59 +++++++++---------- .../ui/components/ZoomableContentDialog.kt | 16 ++--- .../ui/components/ZoomableContentView.kt | 4 +- .../amethyst/ui/components/HdrRequestsTest.kt | 34 ++++------- 5 files changed, 55 insertions(+), 68 deletions(-) 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 index 2f4df413b4..4c94193825 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt @@ -65,8 +65,8 @@ class HdrWindowModeTest { rule.setContent { window = getActivityWindow() - if (showFirst) RequestHdrFor(image, HdrRequests.FEED_HEADROOM) - if (showSecond) RequestHdrFor(image, HdrRequests.FEED_HEADROOM) + if (showFirst) RequestHdrFor(image, fullscreen = false) + if (showSecond) RequestHdrFor(image, fullscreen = false) } rule.waitForIdle() assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) @@ -85,7 +85,7 @@ class HdrWindowModeTest { var window: Window? = null rule.setContent { window = getActivityWindow() - RequestHdrFor(sdrImage(), HdrRequests.FEED_HEADROOM) + RequestHdrFor(sdrImage(), fullscreen = false) } rule.waitForIdle() assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode) @@ -101,7 +101,7 @@ class HdrWindowModeTest { activityWindow = getActivityWindow() Dialog(onDismissRequest = {}) { dialogWindow = getDialogWindow() - RequestHdrFor(image, HdrRequests.UNCAPPED) + RequestHdrFor(image, fullscreen = true) } } rule.waitForIdle() @@ -118,7 +118,7 @@ class HdrWindowModeTest { rule.setContent { window = getActivityWindow() - repeat(count) { RequestHdrFor(image, HdrRequests.FEED_HEADROOM) } + repeat(count) { RequestHdrFor(image, fullscreen = false) } } rule.waitForIdle() rule.runOnUiThread { 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 index 3457150f38..bc44178bbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt @@ -38,25 +38,25 @@ import java.util.WeakHashMap * 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. + * asking for it leaves composition, and then the window gets its [originalColorMode] back. */ -class HdrRequests { - private val tokens = mutableListOf() +class HdrRequests( + val originalColorMode: Int, +) { + private val headrooms = mutableListOf() - private class Request( - val headroom: Float, - ) + val wantsHdr: Boolean get() = headrooms.isNotEmpty() - val wantsHdr: Boolean get() = tokens.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 - /** [UNCAPPED] if any request is uncapped, else the largest cap asked for. */ - val headroom: Float - get() = if (tokens.any { it.headroom == UNCAPPED }) UNCAPPED else tokens.maxOfOrNull { it.headroom } ?: UNCAPPED + fun add(headroom: Float) { + headrooms.add(headroom) + } - fun add(headroom: Float): Any = Request(headroom).also { tokens.add(it) } - - fun remove(token: Any) { - tokens.removeAll { it === token } + /** Drops one request for [headroom]; equal requests are interchangeable. */ + fun remove(headroom: Float) { + headrooms.remove(headroom) } companion object { @@ -71,23 +71,16 @@ class HdrRequests { } } -private class HdrWindowState( - val originalColorMode: Int, -) { - val requests = HdrRequests() -} - // Main-thread only: composition and disposal both run there. -private val windowStates = WeakHashMap() +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(state: HdrWindowState) { - val mode = if (state.requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else state.originalColorMode +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) { - val headroom = if (state.requests.wantsHdr) state.requests.headroom else HdrRequests.UNCAPPED - if (desiredHdrHeadroom != headroom) desiredHdrHeadroom = headroom + if (desiredHdrHeadroom != requests.headroom) desiredHdrHeadroom = requests.headroom } } @@ -96,24 +89,26 @@ fun Image.hasGainmap(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.U /** * 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, - headroom: Float, + 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 state = windowStates.getOrPut(window) { HdrWindowState(window.colorMode) } - val token = state.requests.add(headroom) - window.applyHdr(state) + val requests = windowRequests.getOrPut(window) { HdrRequests(window.colorMode) } + requests.add(headroom) + window.applyHdr(requests) onDispose { - state.requests.remove(token) - window.applyHdr(state) - if (!state.requests.wantsHdr) windowStates.remove(window) + 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 7a010cfe21..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 @@ -184,8 +184,8 @@ fun ZoomableImageDialog( val activityWindow = getActivityWindow() val dialogWindow = getDialogWindow() - // Keyed, not inline: this content recomposes on every frame of a pager swipe (the image - // bounds it reads move), and each assignment below is a window-manager round trip. + // 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 @@ -226,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, @@ -243,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, @@ -282,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 4bcce185aa..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 @@ -458,7 +458,7 @@ fun LocalImageView( SubcomposeAsyncImageContent(loadedImageModifier) val image = (state as AsyncImagePainter.State.Success).result.image - RequestHdrFor(image, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + RequestHdrFor(image, fullscreen = fullResolution) SideEffect { MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height) @@ -602,7 +602,7 @@ fun UrlImageView( ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) val image = (state as AsyncImagePainter.State.Success).result.image - RequestHdrFor(image, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + RequestHdrFor(image, fullscreen = fullResolution) SideEffect { 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 index c014b9787d..bc594b173d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt @@ -26,28 +26,31 @@ import org.junit.Assert.assertTrue import org.junit.Test class HdrRequestsTest { + private fun requests() = HdrRequests(originalColorMode = 0) + @Test fun noRequestsMeansSdr() { - val requests = HdrRequests() + val requests = requests() assertFalse(requests.wantsHdr) + assertEquals(HdrRequests.UNCAPPED, requests.headroom) } @Test fun hdrStaysOnUntilTheLastRequestLeaves() { - val requests = HdrRequests() - val first = requests.add(2f) - val second = requests.add(2f) + val requests = requests() + requests.add(2f) + requests.add(2f) - requests.remove(first) + requests.remove(2f) assertTrue(requests.wantsHdr) - requests.remove(second) + requests.remove(2f) assertFalse(requests.wantsHdr) } @Test fun theLargestCappedHeadroomWins() { - val requests = HdrRequests() + val requests = requests() requests.add(2f) requests.add(3f) assertEquals(3f, requests.headroom) @@ -55,23 +58,12 @@ class HdrRequestsTest { @Test fun anUncappedRequestLiftsTheCap() { - val requests = HdrRequests() + val requests = requests() requests.add(2f) - val fullscreen = requests.add(HdrRequests.UNCAPPED) + requests.add(HdrRequests.UNCAPPED) assertEquals(HdrRequests.UNCAPPED, requests.headroom) - requests.remove(fullscreen) + requests.remove(HdrRequests.UNCAPPED) assertEquals(2f, requests.headroom) } - - @Test - fun equalHeadroomsAreStillSeparateRequests() { - val requests = HdrRequests() - val first = requests.add(2f) - requests.add(2f) - - requests.remove(first) - requests.remove(first) - assertTrue("removing one token twice must not release the other", requests.wantsHdr) - } } From ef532220afa9a59fb9344a023dc719592214e032 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 30 Sep 2026 12:45:13 +0200 Subject: [PATCH 4/4] test: release bitmaps in the HDR gain map pipeline test Per Kotlin review: each test built a fresh 4000x3000 Ultra HDR fixture (~48 MB) and fully decoded large bitmaps without releasing them, which can flake with OOM on constrained CI devices. Recycle the fixture and every direct decode, gain map contents included (the gain map is a separate bitmap). Coil's bitmaps stay owned by Coil: shut the loaders down instead of recycling what their memory cache may hand out again. --- .../HdrGainmapPipelineInstrumentedTest.kt | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) 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 index 3e301ea775..cd01f55818 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt @@ -74,7 +74,12 @@ class HdrGainmapPipelineInstrumentedTest { 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") - hdrJpeg.outputStream().use { ultraHdrBitmap().compress(Bitmap.CompressFormat.JPEG, 95, it) } + val fixture = ultraHdrBitmap() + try { + hdrJpeg.outputStream().use { fixture.compress(Bitmap.CompressFormat.JPEG, 95, it) } + } finally { + fixture.recycleWithGainmap() + } } @After @@ -122,18 +127,24 @@ class HdrGainmapPipelineInstrumentedTest { @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() - 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()) + 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() } } @@ -177,10 +188,14 @@ class HdrGainmapPipelineInstrumentedTest { assertTrue("gain map lost on a $expected load", (result.image as BitmapImage).bitmap.hasGainmap()) } - load(DataSource.NETWORK) - load(DataSource.MEMORY_CACHE) - memoryCache.clear() - load(DataSource.DISK) + try { + load(DataSource.NETWORK) + load(DataSource.MEMORY_CACHE) + memoryCache.clear() + load(DataSource.DISK) + } finally { + loader.shutdown() + } } cacheDir.deleteRecursively() } @@ -195,7 +210,21 @@ class HdrGainmapPipelineInstrumentedTest { private fun bounds(file: File) = BitmapFactory.Options().apply { inJustDecodeBounds = true }.also { BitmapFactory.decodeFile(file.absolutePath, it) } - private fun decodesWithGainmap(file: File): Boolean = BitmapFactory.decodeFile(file.absolutePath)?.hasGainmap() == true + // 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,