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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.WindowManager
|
||||
@@ -44,6 +45,7 @@ import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -182,20 +184,29 @@ fun ZoomableImageDialog(
|
||||
val activityWindow = getActivityWindow()
|
||||
val dialogWindow = getDialogWindow()
|
||||
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
// Preserve any brightness override already applied to the dialog window (e.g. by the
|
||||
// fullscreen swipe controls). This block re-runs on recomposition (orientation change
|
||||
// re-reads `orientation` above), and copying the activity attributes would otherwise
|
||||
// reset screenBrightness and snap the user's brightness back mid-session.
|
||||
val currentBrightness = dialogWindow.attributes.screenBrightness
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = dialogWindow.attributes.type
|
||||
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
||||
attributes.dimAmount = 0f
|
||||
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
||||
attributes.screenBrightness = currentBrightness
|
||||
dialogWindow.attributes = attributes
|
||||
// Keyed, not inline: each assignment below is a window-manager round trip, so it must run
|
||||
// on orientation changes only, not on every recomposition of this content.
|
||||
DisposableEffect(orientation, activityWindow, dialogWindow) {
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
// Preserve what the dialog window owns: the brightness override applied by the
|
||||
// fullscreen swipe controls, and the HDR mode [RequestHdrFor] manages for the
|
||||
// image on screen. Copying the activity's instead would snap brightness back
|
||||
// mid-session and cap or force HDR by whatever the feed behind happens to show.
|
||||
val current = dialogWindow.attributes
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = current.type
|
||||
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
||||
attributes.dimAmount = 0f
|
||||
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
||||
attributes.screenBrightness = current.screenBrightness
|
||||
attributes.colorMode = current.colorMode
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
attributes.desiredHdrHeadroom = current.desiredHdrHeadroom
|
||||
}
|
||||
dialogWindow.attributes = attributes
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
|
||||
@@ -215,9 +226,9 @@ fun ZoomableImageDialog(
|
||||
allImages = allImages,
|
||||
imageUrl = imageUrl,
|
||||
sourceBounds = sourceBounds,
|
||||
imageBounds = imageBounds,
|
||||
imageBounds = { imageBounds },
|
||||
onImageBoundsChanged = updateImageBounds,
|
||||
currentZoomState = currentZoomState,
|
||||
currentZoomState = { currentZoomState },
|
||||
onZoomStateChanged = { currentZoomState = it },
|
||||
progress = progressProvider,
|
||||
onDismiss = dismissWithAnimation,
|
||||
@@ -232,9 +243,9 @@ private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
sourceBounds: Rect?,
|
||||
imageBounds: Rect?,
|
||||
imageBounds: () -> Rect?,
|
||||
onImageBoundsChanged: (Rect) -> Unit,
|
||||
currentZoomState: ZoomState?,
|
||||
currentZoomState: () -> ZoomState?,
|
||||
onZoomStateChanged: (ZoomState) -> Unit,
|
||||
progress: () -> Float,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -271,12 +282,12 @@ private fun DialogContent(
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val src = sourceBounds
|
||||
val img = imageBounds
|
||||
val img = imageBounds()
|
||||
if (src != null && img != null && src.hasArea() && img.hasArea()) {
|
||||
// Account for user-applied zoom: the exit animation must start from
|
||||
// the visible bounds, not the unzoomed layout bounds — otherwise
|
||||
// dismissing a zoomed-in image jumps.
|
||||
val zoomed = img.zoomedBy(currentZoomState)
|
||||
val zoomed = img.zoomedBy(currentZoomState())
|
||||
// Uniform scale so non-square images keep their aspect ratio during
|
||||
// the grow animation. The image covers the source rect; the overflow
|
||||
// is clipped below.
|
||||
|
||||
+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, fullscreen = fullResolution)
|
||||
|
||||
SideEffect {
|
||||
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
||||
MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height)
|
||||
MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height)
|
||||
}
|
||||
|
||||
content.isVerified?.let {
|
||||
@@ -599,9 +601,11 @@ fun UrlImageView(
|
||||
|
||||
ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd))
|
||||
|
||||
val image = (state as AsyncImagePainter.State.Success).result.image
|
||||
RequestHdrFor(image, fullscreen = fullResolution)
|
||||
|
||||
SideEffect {
|
||||
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
||||
MediaAspectRatioCache.add(content.url, drawable.width, drawable.height)
|
||||
MediaAspectRatioCache.add(content.url, image.width, image.height)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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