fix(android): shrink chat image attachments
This commit is contained in:
parent
a41be2585f
commit
3e360ec8cb
@ -1,7 +1,5 @@
|
|||||||
package ai.openclaw.app.ui.chat
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
|||||||
image =
|
image =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
|
||||||
bitmap.asImageBitmap()
|
bitmap.asImageBitmap()
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
|
|||||||
@ -0,0 +1,150 @@
|
|||||||
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import ai.openclaw.app.node.JpegSizeLimiter
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||||
|
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||||
|
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||||
|
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||||
|
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
private val decodedBitmapCache =
|
||||||
|
object : LruCache<String, Bitmap>(CHAT_IMAGE_CACHE_BYTES) {
|
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||||
|
val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/'))
|
||||||
|
val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH)
|
||||||
|
if (bitmap == null) {
|
||||||
|
throw IllegalStateException("unsupported attachment")
|
||||||
|
}
|
||||||
|
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||||
|
val encoded =
|
||||||
|
JpegSizeLimiter.compressToLimit(
|
||||||
|
initialWidth = bitmap.width,
|
||||||
|
initialHeight = bitmap.height,
|
||||||
|
startQuality = CHAT_ATTACHMENT_START_QUALITY,
|
||||||
|
maxBytes = maxBytes,
|
||||||
|
minSize = 240,
|
||||||
|
encode = { width, height, quality ->
|
||||||
|
val working =
|
||||||
|
if (width == bitmap.width && height == bitmap.height) {
|
||||||
|
bitmap
|
||||||
|
} else {
|
||||||
|
bitmap.scale(width, height, true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
|
||||||
|
throw IllegalStateException("attachment encode failed")
|
||||||
|
}
|
||||||
|
out.toByteArray()
|
||||||
|
} finally {
|
||||||
|
if (working !== bitmap) {
|
||||||
|
working.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP)
|
||||||
|
return PendingImageAttachment(
|
||||||
|
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
base64 = base64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? {
|
||||||
|
val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}"
|
||||||
|
decodedBitmapCache.get(cacheKey)?.let { return it }
|
||||||
|
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val bitmap =
|
||||||
|
BitmapFactory.decodeByteArray(
|
||||||
|
bytes,
|
||||||
|
0,
|
||||||
|
bytes.size,
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||||
|
inPreferredConfig = Bitmap.Config.RGB_565
|
||||||
|
},
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
decodedBitmapCache.put(cacheKey, bitmap)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||||
|
if (width <= 0 || height <= 0 || maxDimension <= 0) return 1
|
||||||
|
|
||||||
|
var sample = 1
|
||||||
|
var longestEdge = max(width, height)
|
||||||
|
while (longestEdge > maxDimension && sample < 64) {
|
||||||
|
sample *= 2
|
||||||
|
longestEdge = max(width / sample, height / sample)
|
||||||
|
}
|
||||||
|
return sample.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeAttachmentFileName(raw: String): String {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isEmpty()) return "image.jpg"
|
||||||
|
val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" }
|
||||||
|
return "$stem.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeScaledBitmap(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxDimension: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
resolver.openInputStream(uri).use { input ->
|
||||||
|
if (input == null) return null
|
||||||
|
BitmapFactory.decodeStream(input, null, bounds)
|
||||||
|
}
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val decoded =
|
||||||
|
resolver.openInputStream(uri).use { input ->
|
||||||
|
if (input == null) return null
|
||||||
|
BitmapFactory.decodeStream(
|
||||||
|
input,
|
||||||
|
null,
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val longestEdge = max(decoded.width, decoded.height)
|
||||||
|
if (longestEdge <= maxDimension) return decoded
|
||||||
|
|
||||||
|
val scale = maxDimension.toDouble() / longestEdge.toDouble()
|
||||||
|
val targetWidth = max(1, (decoded.width * scale).roundToInt())
|
||||||
|
val targetHeight = max(1, (decoded.height * scale).roundToInt())
|
||||||
|
val scaled = decoded.scale(targetWidth, targetHeight, true)
|
||||||
|
if (scaled !== decoded) {
|
||||||
|
decoded.recycle()
|
||||||
|
}
|
||||||
|
return scaled
|
||||||
|
}
|
||||||
@ -1,8 +1,5 @@
|
|||||||
package ai.openclaw.app.ui.chat
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
@ -47,7 +44,6 @@ import ai.openclaw.app.ui.mobileDanger
|
|||||||
import ai.openclaw.app.ui.mobileDangerSoft
|
import ai.openclaw.app.ui.mobileDangerSoft
|
||||||
import ai.openclaw.app.ui.mobileText
|
import ai.openclaw.app.ui.mobileText
|
||||||
import ai.openclaw.app.ui.mobileTextSecondary
|
import ai.openclaw.app.ui.mobileTextSecondary
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val next =
|
val next =
|
||||||
uris.take(8).mapNotNull { uri ->
|
uris.take(8).mapNotNull { uri ->
|
||||||
try {
|
try {
|
||||||
loadImageAttachment(resolver, uri)
|
loadSizedImageAttachment(resolver, uri)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -217,24 +213,3 @@ data class PendingImageAttachment(
|
|||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val base64: String,
|
val base64: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
|
||||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
|
||||||
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
|
||||||
val bytes =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
resolver.openInputStream(uri)?.use { input ->
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
input.copyTo(out)
|
|
||||||
out.toByteArray()
|
|
||||||
} ?: ByteArray(0)
|
|
||||||
}
|
|
||||||
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
|
||||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
|
||||||
return PendingImageAttachment(
|
|
||||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
|
||||||
fileName = fileName,
|
|
||||||
mimeType = mimeType,
|
|
||||||
base64 = base64,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatImageCodecTest {
|
||||||
|
@Test
|
||||||
|
fun computeInSampleSizeCapsLongestEdge() {
|
||||||
|
assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600))
|
||||||
|
assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizeAttachmentFileNameForcesJpegExtension() {
|
||||||
|
assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png"))
|
||||||
|
assertEquals("image.jpg", normalizeAttachmentFileName(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user