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:
parent
1952c3eabb
commit
d77e9cec29
6 changed files with 39 additions and 77 deletions
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue