feat: upgrade NetworkImageCache — disk cache, shared client, crossfade

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.
This commit is contained in:
Bruno Deanoz 2026-06-23 13:00:49 +02:00
parent 38678a2add
commit 0f6e55d77a
2 changed files with 52 additions and 18 deletions

View file

@ -62,6 +62,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.Attachment 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.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows 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.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -90,9 +92,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit
import dev.kaizen.app.ui.icon.KaizenIcons import dev.kaizen.app.ui.icon.KaizenIcons
@Composable @Composable
@ -951,39 +951,69 @@ private fun StopButton(onClick: () -> Unit) {
// ── Attachment rendering ──────────────────────────────────────────────────── // ── Attachment rendering ────────────────────────────────────────────────────
private object NetworkImageCache { private object NetworkImageCache {
private const val MAX_MEMORY_BYTES = 32 * 1024 * 1024L // 32MB
private var currentBytes = 0L
private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) { private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) : 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) { suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
synchronized(cache) { cache[url] }?.let { return@withContext it } synchronized(cache) { cache[url] }?.let { return@withContext it }
try { try {
val bytes = diskFile(url)?.takeIf { it.exists() }?.readBytes()
?: run {
val req = Request.Builder().url(url).build() val req = Request.Builder().url(url).build()
client.newCall(req).execute().use { resp -> KaizenApi.imageClient.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null if (!resp.isSuccessful) return@withContext null
val bytes = resp.body!!.bytes() resp.body!!.bytes().also { b -> diskFile(url)?.writeBytes(b) }
}
}
val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true } val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe)
val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536)
val opts = BitmapFactory.Options().apply { inSampleSize = scale } val opts = BitmapFactory.Options().apply { inSampleSize = scale }
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } } val imgBmp = bmp.asImageBitmap()
synchronized(cache) {
currentBytes += imgBmp.width.toLong() * imgBmp.height * 4
cache[url] = imgBmp
} }
imgBmp
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
} }
@Composable @Composable
private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) { 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<ImageBitmap?>(null) } var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) } LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
val bmp = bitmap val bmp = bitmap
if (bmp != null) { androidx.compose.animation.Crossfade(targetState = bmp, label = "img") { img ->
Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale) if (img != null) {
Image(bitmap = img, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale)
} else { } else {
Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))) Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)))
} }
}
} }
@Composable @Composable

View file

@ -170,6 +170,10 @@ object KaizenApi {
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
.build() .build()
val imageClient: OkHttpClient = client.newBuilder()
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType() private val jsonMedia = "application/json".toMediaType()