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:
davotoula
2026-09-30 11:44:34 +02:00
parent 8855e75345
commit e98c7ba17e
5 changed files with 576 additions and 4 deletions
@@ -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
}
}
@@ -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)
}
}
}
@@ -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)
}
}