From 0f6e55d77ae8ec33b018fcc5d3f64ec95af4c8a6 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 23 Jun 2026 13:00:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20upgrade=20NetworkImageCache=20=E2=80=94?= =?UTF-8?q?=20disk=20cache,=20shared=20client,=20crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared OkHttpClient via KaizenApi.imageClient (reuses connection pool), file-based disk cache in cacheDir/img (survives app restart), 32MB size-based memory limit instead of 50-entry count, Crossfade animation on image load. --- .../dev/kaizen/app/chat/ChatComponents.kt | 66 ++++++++++++++----- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 4 ++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index e234425..82f6dcb 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.kaizen.app.net.Attachment +import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.ui.effect.GlassSurface import dev.kaizen.app.ui.effect.GlassTiers import dev.kaizen.app.ui.effect.KaizenShadows @@ -80,6 +81,7 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import dev.kaizen.app.haptics.rememberHaptics import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch @@ -90,9 +92,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request -import java.util.concurrent.TimeUnit import dev.kaizen.app.ui.icon.KaizenIcons @Composable @@ -951,38 +951,68 @@ private fun StopButton(onClick: () -> Unit) { // ── Attachment rendering ──────────────────────────────────────────────────── private object NetworkImageCache { + private const val MAX_MEMORY_BYTES = 32 * 1024 * 1024L // 32MB + private var currentBytes = 0L private val cache = object : LinkedHashMap(32, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 50 + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) : Boolean { + if (currentBytes <= MAX_MEMORY_BYTES) return false + val bmp = eldest?.value ?: return false + currentBytes -= bmp.width.toLong() * bmp.height * 4 + return true + } + } + + private var diskCacheDir: java.io.File? = null + + fun init(context: android.content.Context) { + if (diskCacheDir == null) diskCacheDir = java.io.File(context.cacheDir, "img").also { it.mkdirs() } + } + + private fun diskFile(url: String): java.io.File? { + val dir = diskCacheDir ?: return null + val hash = url.hashCode().toUInt().toString(16) + return java.io.File(dir, hash) } - private val client = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build() suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) { synchronized(cache) { cache[url] }?.let { return@withContext it } try { - val req = Request.Builder().url(url).build() - client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@withContext null - val bytes = resp.body!!.bytes() - val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) - val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) - val opts = BitmapFactory.Options().apply { inSampleSize = scale } - val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null - bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } } + val bytes = diskFile(url)?.takeIf { it.exists() }?.readBytes() + ?: run { + val req = Request.Builder().url(url).build() + KaizenApi.imageClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null + resp.body!!.bytes().also { b -> diskFile(url)?.writeBytes(b) } + } + } + val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) + val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) + val opts = BitmapFactory.Options().apply { inSampleSize = scale } + val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null + val imgBmp = bmp.asImageBitmap() + synchronized(cache) { + currentBytes += imgBmp.width.toLong() * imgBmp.height * 4 + cache[url] = imgBmp } + imgBmp } catch (_: Exception) { null } } } @Composable private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) { + val context = LocalContext.current + LaunchedEffect(Unit) { NetworkImageCache.init(context) } var bitmap by remember(url) { mutableStateOf(null) } LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) } val bmp = bitmap - if (bmp != null) { - Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale) - } else { - Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))) + androidx.compose.animation.Crossfade(targetState = bmp, label = "img") { img -> + if (img != null) { + Image(bitmap = img, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale) + } else { + Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))) + } } } diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 0c1b3da..e202d0e 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -170,6 +170,10 @@ object KaizenApi { .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream .build() + val imageClient: OkHttpClient = client.newBuilder() + .readTimeout(30, TimeUnit.SECONDS) + .build() + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val jsonMedia = "application/json".toMediaType()