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.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,40 +951,70 @@ 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<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) {
synchronized(cache) { cache[url] }?.let { return@withContext it }
try {
val bytes = diskFile(url)?.takeIf { it.exists() }?.readBytes()
?: run {
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
val bytes = resp.body!!.bytes()
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
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 }
}
}
@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<ImageBitmap?>(null) }
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
val bmp = bitmap
if (bmp != null) {
Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale)
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)))
}
}
}
@Composable
fun AttachmentGrid(attachments: List<Attachment>, modifier: Modifier = Modifier) {

View file

@ -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()