diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt index 3d690db7f..96e8aa163 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt @@ -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) ) } } diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt index 9da512d70..8df0f31f5 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt @@ -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): WsIncoming { + fun toWsIncoming(text: String, base64Images: List): WsIncoming { return WsIncoming( content = text, - images = if (images.isNotEmpty()) images.map { it.base64 } else null + images = base64Images.ifEmpty { null } ) } } diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt index ed517a6a4..565b13127 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt @@ -48,10 +48,10 @@ class ChatRepositoryImpl( } override suspend fun sendMessage(text: String, images: List) { - 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)) } diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt index 0b06f6336..39b577f5c 100644 --- a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt +++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt @@ -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" ) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt index f15bc13e3..974375239 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt @@ -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, - contentDescription = null, - modifier = Modifier - .size(64.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop - ) - } + 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 diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt index b474c3d75..47d0cf206 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt @@ -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 - ) - ) - ) - } + viewModel.onEvent( + 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" - viewModel.onEvent( - ChatEvent.OnImageAdded( - ImageAttachment( - uri = it.toString(), - base64 = base64, - mimeType = mimeType - ) - ) - ) - } + val mimeType = context.contentResolver.getType(it) ?: "image/jpeg" + viewModel.onEvent( + 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 - } -}