perf: eliminate Base64 from image attachment flow for instant preview

Replace eager Base64 encode/decode with URI-based approach:
- Preview loads directly from URI via Coil (zero processing on attach)
- File save, dimension read, and Base64 encode happen at send time only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-18 21:28:00 +09:00
parent 1952c3eabb
commit d77e9cec29
6 changed files with 39 additions and 77 deletions

View file

@ -2,27 +2,35 @@ package io.picoclaw.android.core.data.local
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import io.picoclaw.android.core.domain.model.ImageData
import java.io.File
import java.util.UUID
class ImageFileStorage(context: Context) {
class ImageFileStorage(private val context: Context) {
private val imageDir = File(context.filesDir, "chat_images").also { it.mkdirs() }
fun saveBase64ToFile(base64: String): ImageData {
val bytes = Base64.decode(base64, Base64.DEFAULT)
data class SaveResult(
val imageData: ImageData,
val base64: String
)
fun saveFromUri(uriString: String): SaveResult {
val bytes = context.contentResolver.openInputStream(Uri.parse(uriString))?.use {
it.readBytes()
} ?: error("Cannot read URI: $uriString")
val file = File(imageDir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes)
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, opts)
return ImageData(
path = file.absolutePath,
width = opts.outWidth,
height = opts.outHeight
return SaveResult(
imageData = ImageData(file.absolutePath, opts.outWidth, opts.outHeight),
base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
)
}
}

View file

@ -4,7 +4,6 @@ import io.picoclaw.android.core.data.local.entity.MessageEntity
import io.picoclaw.android.core.data.remote.dto.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.model.ImageData
import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.domain.model.MessageStatus
@ -65,10 +64,10 @@ object MessageMapper {
)
}
fun toWsIncoming(text: String, images: List<ImageAttachment>): WsIncoming {
fun toWsIncoming(text: String, base64Images: List<String>): WsIncoming {
return WsIncoming(
content = text,
images = if (images.isNotEmpty()) images.map { it.base64 } else null
images = base64Images.ifEmpty { null }
)
}
}

View file

@ -48,10 +48,10 @@ class ChatRepositoryImpl(
}
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
val imageDataList = images.map { imageFileStorage.saveBase64ToFile(it.base64) }
val entity = MessageMapper.toEntity(text, imageDataList, MessageStatus.SENDING)
val results = images.map { imageFileStorage.saveFromUri(it.uri) }
val entity = MessageMapper.toEntity(text, results.map { it.imageData }, MessageStatus.SENDING)
messageDao.insert(entity)
val wsDto = MessageMapper.toWsIncoming(text, images)
val wsDto = MessageMapper.toWsIncoming(text, results.map { it.base64 })
val success = webSocketClient.send(wsDto)
messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name))
}

View file

@ -1,7 +1,6 @@
package io.picoclaw.android.core.domain.model
data class ImageAttachment(
val uri: String? = null,
val base64: String,
val mimeType: String = "image/png"
val uri: String,
val mimeType: String = "image/jpeg"
)

View file

@ -1,8 +1,6 @@
package io.picoclaw.android.feature.chat.component
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@ -15,13 +13,12 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.picoclaw.android.core.domain.model.ImageAttachment
@Composable
@ -38,24 +35,14 @@ fun ImagePreviewRow(
) {
itemsIndexed(images) { index, attachment ->
Box {
val bitmap = remember(attachment.base64) {
try {
val bytes = Base64.decode(attachment.base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (_: Exception) {
null
}
}
bitmap?.let {
Image(
bitmap = it,
AsyncImage(
model = Uri.parse(attachment.uri),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
IconButton(
onClick = { onRemove(index) },
modifier = Modifier

View file

@ -1,8 +1,6 @@
package io.picoclaw.android.feature.chat.screen
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
@ -32,7 +30,6 @@ import io.picoclaw.android.feature.chat.component.ImagePreviewRow
import io.picoclaw.android.feature.chat.component.MessageInput
import io.picoclaw.android.feature.chat.component.MessageList
import org.koin.androidx.compose.koinViewModel
import java.io.ByteArrayOutputStream
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@ -51,16 +48,9 @@ fun ChatScreen(
) { success ->
if (success) {
cameraImageUri?.let { uri ->
uriToBase64(context, uri)?.let { base64 ->
viewModel.onEvent(
ChatEvent.OnImageAdded(
ImageAttachment(
uri = uri.toString(),
base64 = base64
ChatEvent.OnImageAdded(ImageAttachment(uri = uri.toString()))
)
)
)
}
}
}
}
@ -69,18 +59,10 @@ fun ChatScreen(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
uriToBase64(context, it)?.let { base64 ->
val mimeType = context.contentResolver.getType(it) ?: "image/png"
val mimeType = context.contentResolver.getType(it) ?: "image/jpeg"
viewModel.onEvent(
ChatEvent.OnImageAdded(
ImageAttachment(
uri = it.toString(),
base64 = base64,
mimeType = mimeType
ChatEvent.OnImageAdded(ImageAttachment(uri = it.toString(), mimeType = mimeType))
)
)
)
}
}
}
@ -142,16 +124,3 @@ fun ChatScreen(
}
}
}
private fun uriToBase64(context: android.content.Context, uri: Uri): String? {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
val bitmap = BitmapFactory.decodeStream(input)
val output = ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output)
Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
} catch (_: Exception) {
null
}
}