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:
parent
38678a2add
commit
0f6e55d77a
2 changed files with 52 additions and 18 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue