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.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64 import android.util.Base64
import io.picoclaw.android.core.domain.model.ImageData import io.picoclaw.android.core.domain.model.ImageData
import java.io.File import java.io.File
import java.util.UUID 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() } private val imageDir = File(context.filesDir, "chat_images").also { it.mkdirs() }
fun saveBase64ToFile(base64: String): ImageData { data class SaveResult(
val bytes = Base64.decode(base64, Base64.DEFAULT) 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") val file = File(imageDir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes) file.writeBytes(bytes)
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, opts) BitmapFactory.decodeFile(file.absolutePath, opts)
return ImageData( return SaveResult(
path = file.absolutePath, imageData = ImageData(file.absolutePath, opts.outWidth, opts.outHeight),
width = opts.outWidth, base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
height = opts.outHeight
) )
} }
} }

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.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing import io.picoclaw.android.core.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ChatMessage 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.ImageData
import io.picoclaw.android.core.domain.model.MessageSender import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.domain.model.MessageStatus 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( return WsIncoming(
content = text, 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>) { override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
val imageDataList = images.map { imageFileStorage.saveBase64ToFile(it.base64) } val results = images.map { imageFileStorage.saveFromUri(it.uri) }
val entity = MessageMapper.toEntity(text, imageDataList, MessageStatus.SENDING) val entity = MessageMapper.toEntity(text, results.map { it.imageData }, MessageStatus.SENDING)
messageDao.insert(entity) messageDao.insert(entity)
val wsDto = MessageMapper.toWsIncoming(text, images) val wsDto = MessageMapper.toWsIncoming(text, results.map { it.base64 })
val success = webSocketClient.send(wsDto) val success = webSocketClient.send(wsDto)
messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name)) 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 package io.picoclaw.android.core.domain.model
data class ImageAttachment( data class ImageAttachment(
val uri: String? = null, val uri: String,
val base64: String, val mimeType: String = "image/jpeg"
val mimeType: String = "image/png"
) )

View file

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

View file

@ -1,8 +1,6 @@
package io.picoclaw.android.feature.chat.screen package io.picoclaw.android.feature.chat.screen
import android.graphics.BitmapFactory
import android.net.Uri 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.layout.Column 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.MessageInput
import io.picoclaw.android.feature.chat.component.MessageList import io.picoclaw.android.feature.chat.component.MessageList
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -51,16 +48,9 @@ fun ChatScreen(
) { success -> ) { success ->
if (success) { if (success) {
cameraImageUri?.let { uri -> cameraImageUri?.let { uri ->
uriToBase64(context, uri)?.let { base64 ->
viewModel.onEvent( viewModel.onEvent(
ChatEvent.OnImageAdded( ChatEvent.OnImageAdded(ImageAttachment(uri = uri.toString()))
ImageAttachment(
uri = uri.toString(),
base64 = base64
) )
)
)
}
} }
} }
} }
@ -69,18 +59,10 @@ fun ChatScreen(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri -> ) { uri ->
uri?.let { uri?.let {
uriToBase64(context, it)?.let { base64 -> val mimeType = context.contentResolver.getType(it) ?: "image/jpeg"
val mimeType = context.contentResolver.getType(it) ?: "image/png"
viewModel.onEvent( viewModel.onEvent(
ChatEvent.OnImageAdded( ChatEvent.OnImageAdded(ImageAttachment(uri = it.toString(), mimeType = mimeType))
ImageAttachment(
uri = it.toString(),
base64 = base64,
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
}
}