mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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.
This commit is contained in:
+228
@@ -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<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> {
|
||||
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<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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+144
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Request>()
|
||||
|
||||
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<Window, HdrWindowState>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user