feat(desktop): collapse icon decoding to a single memoized lazy

Phase 5.1 of the launch-optimization plan: the cold-boot critical path
loaded /icon.png up to four separate times (taskbar setup, Window icon,
Tor splash, account-loading splash). Two of those sites also paid an
ImageIO.read to obtain a BufferedImage, and the Window-icon site
additionally round-tripped the image back through ImageIO.write so Skia
could re-decode it.

IconResources holds one lazy each for the bytes, the decoded
BufferedImage, the platform-adapted BufferedImage (squircle on macOS),
and the two BitmapPainters (raw + adapted). All four call sites in
Main.kt now consume the cached values directly — no remember, no
re-decode.

IconResourcesTest pins the memoization invariants (same instance on
repeated access). All 271 desktopApp tests pass.

End-to-end delta vs the baseline will be measured once Phase 3
benchmarks land; the worst-case savings on cold boot are two
ImageIO.read calls plus three resource reads plus one ImageIO.write,
all on the main thread.
This commit is contained in:
nrobi144
2026-06-17 11:40:22 +03:00
parent ff55898ab9
commit b338d7db4b
3 changed files with 174 additions and 42 deletions
@@ -63,8 +63,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
import androidx.compose.ui.unit.dp
@@ -216,13 +214,7 @@ fun main() {
// on macOS the logo is then wrapped in a squircle so it matches
// first-party dock icons.
try {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
raw?.let {
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(it)
}
val adapted = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBufferedImage
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
val taskbar = java.awt.Taskbar.getTaskbar()
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
@@ -297,21 +289,7 @@ fun main() {
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
// is wrapped in a squircle so it matches every other dock icon; on
// other platforms the raw transparent logo is used as-is.
val appIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(raw)
val buf = java.io.ByteArrayOutputStream()
javax.imageio.ImageIO.write(adapted, "png", buf)
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(buf.toByteArray())
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val appIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBitmapPainter
Window(
onCloseRequest = ::exitApplication,
@@ -713,15 +691,7 @@ fun App(
val torStatus by torManager.status.collectAsState()
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
val splashIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(bytes)
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
@@ -984,15 +954,7 @@ fun App(
when (accountState) {
is AccountState.Loading -> {
// Branded loading screen while accounts load from storage
val loadingIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(bytes)
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -0,0 +1,89 @@
/*
* 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.desktop.platform
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
/**
* Lazily-memoized icon resources shared across all consumers of `/icon.png`.
*
* Before this object existed the desktop launch path read and decoded the
* icon four separate times on the cold-boot critical path (taskbar setup,
* Window icon, Tor splash, account-loading splash). The taskbar and Window
* sites also paid an `ImageIO.read` to obtain a `BufferedImage` before
* either passing it straight to `Taskbar.iconImage` (taskbar) or
* round-tripping it back through `ImageIO.write` so Skia can decode it
* (Window icon).
*
* This object collapses the work to one resource read plus at most one
* decode per output shape. All properties are `lazy { ... }` so the cost
* is paid only when first observed.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 5.1.
*/
object IconResources {
/** Raw PNG bytes read from the `/icon.png` classpath resource. */
val iconBytes: ByteArray by lazy {
IconResources::class.java.getResourceAsStream("/icon.png")!!.readBytes()
}
/** Decoded `BufferedImage` for AWT consumers (Taskbar, Window icon source). */
val rawBufferedImage: BufferedImage by lazy {
ImageIO.read(ByteArrayInputStream(iconBytes))
}
/**
* Platform-adapted icon (squircle on macOS, raw transparent PNG elsewhere).
* Returns `null` when adaptation fails or is unsupported — callers should
* fall back to [rawBufferedImage].
*/
val adaptedBufferedImage: BufferedImage? by lazy {
PlatformAppIcon.adaptForHost(rawBufferedImage)
}
/**
* Compose `BitmapPainter` for the raw PNG bytes. Used by splash screens
* (Tor connecting, account loading) that paint the un-adapted logo with
* a Material `tint`.
*/
val rawBitmapPainter: BitmapPainter by lazy {
BitmapPainter(Image.makeFromEncoded(iconBytes).toComposeImageBitmap())
}
/**
* Compose `BitmapPainter` for the platform-adapted icon. Used by the
* main `Window(icon = …)` parameter so the title-bar / taskbar thumbnail
* matches the dock icon shape on macOS.
*/
val adaptedBitmapPainter: BitmapPainter by lazy {
val adapted = adaptedBufferedImage ?: rawBufferedImage
val buf = ByteArrayOutputStream()
ImageIO.write(adapted, "png", buf)
BitmapPainter(Image.makeFromEncoded(buf.toByteArray()).toComposeImageBitmap())
}
}
@@ -0,0 +1,81 @@
/*
* 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.desktop.platform
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Phase 5.1 of the launch-optimization plan: pin the memoization invariants
* of [IconResources]. The launch path previously decoded the same `/icon.png`
* resource up to four times during a single cold boot; this object collapses
* the work and the tests below assert that the lazy holders return the same
* cached instance on subsequent accesses.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 5.1.
*/
class IconResourcesTest {
@Test
fun iconBytesAreMemoized() {
val first = IconResources.iconBytes
val second = IconResources.iconBytes
assertSame(first, second, "Raw PNG byte array must be the same instance across calls")
assertTrue(first.isNotEmpty(), "Bundled /icon.png must not be empty")
}
@Test
fun rawBufferedImageIsMemoized() {
val first = IconResources.rawBufferedImage
val second = IconResources.rawBufferedImage
assertSame(first, second, "Decoded BufferedImage must be the same instance across calls")
assertTrue(first.width > 0 && first.height > 0, "Decoded image must have positive dimensions")
}
@Test
fun rawBitmapPainterIsMemoized() {
val first = IconResources.rawBitmapPainter
val second = IconResources.rawBitmapPainter
assertSame(first, second, "Raw BitmapPainter must be the same instance across calls")
}
@Test
fun adaptedBitmapPainterIsMemoized() {
val first = IconResources.adaptedBitmapPainter
val second = IconResources.adaptedBitmapPainter
assertSame(first, second, "Adapted BitmapPainter must be the same instance across calls")
}
@Test
fun adaptedBufferedImageEitherProducesAValueOrIsNull() {
// On macOS this returns a squircle; on other platforms it may return null
// when PlatformAppIcon.adaptForHost is a no-op. Either is acceptable — we
// just verify the lazy doesn't throw.
val adapted = IconResources.adaptedBufferedImage
// Accessing twice must yield the same instance (or both null).
assertSame(adapted, IconResources.adaptedBufferedImage)
if (adapted != null) {
assertNotNull(adapted, "If adaptation returns non-null, it must be a valid image")
}
}
}