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