Skip to content

Commit cf577d0

Browse files
committed
Add rendering API fallback logic and improve PNG rendering robustness
- Introduced rendering API fallback mechanism for DirectX12, OpenGL, and Software rendering in `ComposableIconUtils`. - Enhanced error handling during composable rendering to automatically retry with alternative APIs. - Improved cleanup of resources to prevent memory leaks. - Added logging for debug and error scenarios related to rendering. - Enabled native tray logging in the demo application for better diagnostics. Signed-off-by: Elie G. <elyahou.hadass@gmail.com>
1 parent 669ca0d commit cf577d0

2 files changed

Lines changed: 209 additions & 48 deletions

File tree

demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/TrayAppDemo.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import androidx.compose.foundation.border
55
import androidx.compose.foundation.layout.*
66
import androidx.compose.foundation.shape.RoundedCornerShape
77
import androidx.compose.material.icons.Icons
8-
import androidx.compose.material.icons.filled.Book
98
import androidx.compose.material.icons.filled.Window
109
import androidx.compose.material3.*
1110
import androidx.compose.runtime.*
@@ -18,9 +17,9 @@ import androidx.compose.ui.window.Window
1817
import androidx.compose.ui.window.application
1918
import androidx.compose.ui.window.rememberWindowState
2019
import com.kdroid.composetray.tray.api.ExperimentalTrayAppApi
21-
import com.kdroid.composetray.tray.api.Tray
2220
import com.kdroid.composetray.tray.api.TrayApp
2321
import com.kdroid.composetray.utils.WindowRaise
22+
import com.kdroid.composetray.utils.allowComposeNativeTrayLogging
2423
import composenativetray.demo.generated.resources.Res
2524
import composenativetray.demo.generated.resources.icon
2625
import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode
@@ -30,6 +29,7 @@ import org.jetbrains.compose.resources.painterResource
3029

3130
@OptIn(ExperimentalTrayAppApi::class)
3231
fun main() {
32+
allowComposeNativeTrayLogging = true
3333
setMacOsAdaptiveTitleBar()
3434
application {
3535
var isWindowVisible by remember { mutableStateOf(true) }

src/commonMain/kotlin/com/kdroid/composetray/utils/ComposableIconUtils.kt

Lines changed: 207 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,8 @@ package com.kdroid.composetray.utils
22

33
import androidx.compose.runtime.Composable
44
import androidx.compose.ui.ImageComposeScene
5-
import androidx.compose.ui.use
65
import kotlinx.coroutines.Dispatchers
7-
import org.jetbrains.skia.Bitmap
8-
import org.jetbrains.skia.EncodedImageFormat
9-
import org.jetbrains.skia.FilterMipmap
10-
import org.jetbrains.skia.FilterMode
11-
import org.jetbrains.skia.Image
12-
import org.jetbrains.skia.MipmapMode
6+
import org.jetbrains.skia.*
137
import java.io.File
148
import java.util.zip.CRC32
159

@@ -18,76 +12,222 @@ import java.util.zip.CRC32
1812
*/
1913
object ComposableIconUtils {
2014

15+
private var currentRenderApi: RenderApi = RenderApi.AUTO
16+
17+
enum class RenderApi {
18+
AUTO,
19+
DIRECTX12,
20+
OPENGL,
21+
SOFTWARE
22+
}
23+
24+
init {
25+
// Start with automatic detection, will fallback if needed
26+
configureRenderingApi(RenderApi.AUTO)
27+
}
28+
29+
/**
30+
* Configure Skiko rendering API with fallback mechanism
31+
*/
32+
private fun configureRenderingApi(api: RenderApi) {
33+
try {
34+
when (api) {
35+
RenderApi.AUTO -> {
36+
// Let Skiko choose automatically (usually tries DirectX12 first on Windows)
37+
System.clearProperty("skiko.renderApi")
38+
debugln { "[ComposableIconUtils] Using automatic rendering API selection" }
39+
}
40+
RenderApi.DIRECTX12 -> {
41+
System.setProperty("skiko.renderApi", "DIRECT3D")
42+
debugln { "[ComposableIconUtils] Configured DirectX12 rendering" }
43+
}
44+
RenderApi.OPENGL -> {
45+
System.setProperty("skiko.renderApi", "OPENGL")
46+
System.setProperty("skiko.rendering.api", "OPENGL")
47+
debugln { "[ComposableIconUtils] Configured OpenGL rendering" }
48+
}
49+
RenderApi.SOFTWARE -> {
50+
System.setProperty("skiko.renderApi", "SOFTWARE")
51+
System.setProperty("skiko.softwareRendering", "true")
52+
System.setProperty("skiko.rendering.api", "SOFTWARE")
53+
System.setProperty("skiko.rendering.useGpu", "false")
54+
System.setProperty("skiko.directx.disabled", "true")
55+
debugln { "[ComposableIconUtils] Configured software rendering" }
56+
}
57+
}
58+
currentRenderApi = api
59+
} catch (e: Exception) {
60+
errorln { "[ComposableIconUtils] Failed to configure rendering API: ${e.message}" }
61+
}
62+
}
63+
64+
/**
65+
* Try the next rendering API in the fallback chain
66+
*/
67+
private fun tryNextRenderApi(): Boolean {
68+
return when (currentRenderApi) {
69+
RenderApi.AUTO, RenderApi.DIRECTX12 -> {
70+
debugln { "[ComposableIconUtils] Falling back from $currentRenderApi to OpenGL" }
71+
configureRenderingApi(RenderApi.OPENGL)
72+
true
73+
}
74+
RenderApi.OPENGL -> {
75+
debugln { "[ComposableIconUtils] Falling back from OpenGL to Software rendering" }
76+
configureRenderingApi(RenderApi.SOFTWARE)
77+
true
78+
}
79+
RenderApi.SOFTWARE -> {
80+
errorln { "[ComposableIconUtils] Already using software rendering, no more fallbacks available" }
81+
false
82+
}
83+
}
84+
}
85+
2186
/**
2287
* Renders a Composable to a PNG file and returns the path to the file.
2388
*
2489
* @param iconRenderProperties Properties for rendering the icon
2590
* @param content The Composable content to render
2691
* @return Path to the generated PNG file
92+
* @throws Exception if rendering fails after all fallback attempts
2793
*/
2894
fun renderComposableToPngFile(
2995
iconRenderProperties: IconRenderProperties,
3096
content: @Composable () -> Unit
3197
): String {
3298
val tempFile = createTempFile(suffix = ".png")
33-
val pngData = renderComposableToPngBytes(iconRenderProperties, content)
99+
val pngData = renderComposableToPngBytesWithFallback(iconRenderProperties, content)
34100
tempFile.writeBytes(pngData)
35101
return tempFile.absolutePath
36102
}
37103

104+
/**
105+
* Renders a Composable to PNG bytes with automatic fallback between rendering APIs
106+
*/
107+
private fun renderComposableToPngBytesWithFallback(
108+
iconRenderProperties: IconRenderProperties,
109+
content: @Composable () -> Unit
110+
): ByteArray {
111+
var attempts = 0
112+
val maxAttempts = 3 // Try DirectX/Auto, then OpenGL, then Software
113+
var lastException: Exception? = null
114+
115+
while (attempts < maxAttempts) {
116+
try {
117+
debugln { "[ComposableIconUtils] Render attempt ${attempts + 1} with $currentRenderApi" }
118+
return renderComposableToPngBytes(iconRenderProperties, content)
119+
} catch (e: Exception) {
120+
lastException = e
121+
val errorMessage = e.message ?: ""
122+
val errorClassName = e.javaClass.simpleName
123+
124+
errorln { "[ComposableIconUtils] $errorClassName with ${currentRenderApi}: $errorMessage" }
125+
126+
// Check if the error is related to DirectX, OpenGL, or rendering in general
127+
val isDirectXError = errorMessage.contains("DirectX12", ignoreCase = true) ||
128+
errorMessage.contains("D3D12", ignoreCase = true) ||
129+
errorMessage.contains("Direct3D", ignoreCase = true) ||
130+
errorMessage.contains("choose DirectX12 adapter", ignoreCase = true) ||
131+
errorClassName.contains("RenderException", ignoreCase = true)
132+
133+
val isOpenGLError = errorMessage.contains("OpenGL", ignoreCase = true) ||
134+
errorMessage.contains("GL", ignoreCase = true)
135+
136+
val isRenderingError = errorClassName.contains("Render", ignoreCase = true) ||
137+
errorMessage.contains("render", ignoreCase = true)
138+
139+
// If it's a rendering-related error, try the next API
140+
if (isDirectXError || isOpenGLError || isRenderingError) {
141+
if (tryNextRenderApi()) {
142+
attempts++
143+
continue // Try again with the next API
144+
}
145+
}
146+
147+
// If we can't identify the error or no more fallbacks, throw the exception
148+
break
149+
}
150+
}
151+
152+
// If all attempts failed, throw the last exception
153+
throw lastException ?: Exception("Failed to render composable after $attempts attempts")
154+
}
155+
38156
/**
39157
* Renders a Composable to a PNG image and returns the result as a byte array.
40158
*
41-
* This function creates an [ImageComposeScene] based on the provided [IconRenderProperties],
42-
* renders the Composable content, and encodes the output into PNG format.
43-
* If scaling is required based on the [IconRenderProperties], the rendered content is scaled before encoding.
44-
*
45159
* @param iconRenderProperties Properties for rendering the icon
46160
* @param content The Composable content to render
47161
* @return A byte array containing the rendered PNG image data.
162+
* @throws Exception if rendering fails
48163
*/
164+
@Throws(Exception::class)
49165
fun renderComposableToPngBytes(
50166
iconRenderProperties: IconRenderProperties,
51167
content: @Composable () -> Unit
52168
): ByteArray {
53-
val scene = ImageComposeScene(
54-
width = iconRenderProperties.sceneWidth,
55-
height = iconRenderProperties.sceneHeight,
56-
density = iconRenderProperties.sceneDensity,
57-
coroutineContext = Dispatchers.Unconfined
58-
) {
59-
content()
60-
}
61-
62-
val renderedIcon = scene.use { it.render() }
169+
var scene: ImageComposeScene? = null
170+
var renderedIcon: Image? = null
171+
var scaledBitmap: Bitmap? = null
172+
var scaledImage: Image? = null
63173

64-
val iconData = if (iconRenderProperties.requiresScaling) {
65-
val scaledIcon = Bitmap().apply {
66-
allocN32Pixels(iconRenderProperties.targetWidth, iconRenderProperties.targetHeight)
67-
}
68-
renderedIcon.use {
69-
it.scalePixels(scaledIcon.peekPixels()!!, FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR), true)
174+
try {
175+
// Create the scene - this is where DirectX/OpenGL errors typically occur
176+
scene = ImageComposeScene(
177+
width = iconRenderProperties.sceneWidth,
178+
height = iconRenderProperties.sceneHeight,
179+
density = iconRenderProperties.sceneDensity,
180+
coroutineContext = Dispatchers.Unconfined
181+
) {
182+
content()
70183
}
71-
scaledIcon.use { bitmap ->
72-
Image.makeFromBitmap(bitmap).use { image ->
73-
image.encodeToData(EncodedImageFormat.PNG)!!
184+
185+
// Render the scene - this may also trigger rendering API errors
186+
renderedIcon = scene.render()
187+
188+
val iconData = if (iconRenderProperties.requiresScaling) {
189+
scaledBitmap = Bitmap().apply {
190+
allocN32Pixels(iconRenderProperties.targetWidth, iconRenderProperties.targetHeight)
74191
}
192+
193+
renderedIcon.scalePixels(
194+
scaledBitmap.peekPixels()!!,
195+
FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR),
196+
true
197+
)
198+
199+
scaledImage = Image.makeFromBitmap(scaledBitmap)
200+
scaledImage.encodeToData(EncodedImageFormat.PNG)
201+
?: throw Exception("Failed to encode scaled image to PNG")
202+
} else {
203+
renderedIcon.encodeToData(EncodedImageFormat.PNG)
204+
?: throw Exception("Failed to encode image to PNG")
75205
}
76-
} else {
77-
renderedIcon.use { image ->
78-
image.encodeToData(EncodedImageFormat.PNG)!!
206+
207+
return iconData.bytes
208+
} catch (e: Exception) {
209+
// Re-throw to be handled by the fallback wrapper
210+
throw e
211+
} finally {
212+
// Ensure proper cleanup
213+
try {
214+
scaledImage?.close()
215+
scaledBitmap?.close()
216+
renderedIcon?.close()
217+
scene?.close()
218+
} catch (e: Exception) {
219+
debugln { "[ComposableIconUtils] Error during cleanup: ${e.message}" }
79220
}
80221
}
81-
82-
return iconData.bytes
83222
}
84223

85224
/**
86225
* Renders a Composable to an ICO file and returns the path to the file.
87226
*
88227
* @param iconRenderProperties Properties for rendering the icon
89228
* @param content The Composable content to render
90-
* @return Path to the generated PNG file
229+
* @return Path to the generated ICO file
230+
* @throws Exception if rendering fails after all fallback attempts
91231
*/
92232
fun renderComposableToIcoFile(
93233
iconRenderProperties: IconRenderProperties,
@@ -107,13 +247,14 @@ object ComposableIconUtils {
107247
* @param iconRenderProperties Properties for rendering the icon
108248
* @param content The Composable content to render
109249
* @return Byte array containing the ICO data
250+
* @throws Exception if rendering fails after all fallback attempts
110251
*/
111252
fun renderComposableToIcoBytes(
112253
iconRenderProperties: IconRenderProperties,
113254
content: @Composable () -> Unit
114255
): ByteArray {
115-
// First render to PNG format (which is supported)
116-
val pngBytes = renderComposableToPngBytes(iconRenderProperties, content)
256+
// First render to PNG format (with fallback support)
257+
val pngBytes = renderComposableToPngBytesWithFallback(iconRenderProperties, content)
117258

118259
// Create a simple ICO format wrapper around the PNG data
119260
// ICO header (6 bytes) + ICO directory entry (16 bytes) + PNG data
@@ -159,6 +300,20 @@ object ComposableIconUtils {
159300
return icoData
160301
}
161302

303+
/**
304+
* Resets the rendering API to try again from the beginning of the fallback chain.
305+
* Useful when creating new instances or after configuration changes.
306+
*/
307+
fun resetRenderingApi() {
308+
debugln { "[ComposableIconUtils] Resetting rendering API to AUTO" }
309+
configureRenderingApi(RenderApi.AUTO)
310+
}
311+
312+
/**
313+
* Gets the current rendering API being used
314+
*/
315+
fun getCurrentRenderApi(): String = currentRenderApi.name
316+
162317
/**
163318
* Creates a temporary file that will be deleted when the JVM exits.
164319
*/
@@ -181,12 +336,18 @@ object ComposableIconUtils {
181336
iconRenderProperties: IconRenderProperties,
182337
content: @Composable () -> Unit
183338
): Long {
184-
// Render the composable to PNG bytes
185-
val pngBytes = renderComposableToPngBytes(iconRenderProperties, content)
339+
return try {
340+
// Render the composable to PNG bytes (with fallback support)
341+
val pngBytes = renderComposableToPngBytesWithFallback(iconRenderProperties, content)
186342

187-
// Calculate CRC32 hash of the PNG bytes
188-
val crc = CRC32()
189-
crc.update(pngBytes)
190-
return crc.value
343+
// Calculate CRC32 hash of the PNG bytes
344+
val crc = CRC32()
345+
crc.update(pngBytes)
346+
crc.value
347+
} catch (e: Exception) {
348+
errorln { "[ComposableIconUtils] Failed to calculate content hash: ${e.message}" }
349+
// Return a time-based hash as fallback
350+
System.currentTimeMillis()
351+
}
191352
}
192-
}
353+
}

0 commit comments

Comments
 (0)