diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt index b2b540bdb7a..8180d24bbed 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt @@ -1,7 +1,5 @@ package ai.openclaw.app.ui.chat -import android.graphics.BitmapFactory -import android.util.Base64 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState { image = withContext(Dispatchers.Default) { try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null bitmap.asImageBitmap() } catch (_: Throwable) { null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt new file mode 100644 index 00000000000..6574fa8678d --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -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(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 +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 201832b9fd3..2d8fb255baa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -1,8 +1,5 @@ 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.result.contract.ActivityResultContracts 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.mobileText import ai.openclaw.app.ui.mobileTextSecondary -import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { val next = uris.take(8).mapNotNull { uri -> try { - loadImageAttachment(resolver, uri) + loadSizedImageAttachment(resolver, uri) } catch (_: Throwable) { null } @@ -217,24 +213,3 @@ data class PendingImageAttachment( val mimeType: 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, - ) -} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt new file mode 100644 index 00000000000..c3d55e80494 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -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("")) + } +}