fix: store images as files instead of Base64 in SQLite to avoid CursorWindow crash
Save image data to filesDir/chat_images/ and persist only file paths in DB, preventing SQLiteBlobTooBigException when images exceed 2MB CursorWindow limit. Also includes: - Camera/gallery image attachment implementation - Gradle 9.3.1 / AGP 9 / compileSdk 36 upgrade - Release signing configuration - Minor bug fixes (MessageInput alignment, MessageList ordering) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3eb99d9d1a
commit
862e47823d
21 changed files with 770 additions and 59 deletions
2
android/.gitignore
vendored
2
android/.gitignore
vendored
|
|
@ -11,3 +11,5 @@ local.properties
|
||||||
/app/build
|
/app/build
|
||||||
/feature/*/build
|
/feature/*/build
|
||||||
/core/*/build
|
/core/*/build
|
||||||
|
keystore.properties
|
||||||
|
keystore/
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,46 @@
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.serialization)
|
alias(libs.plugins.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystorePropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.picoclaw.android"
|
namespace = "io.picoclaw.android"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "io.picoclaw.android"
|
applicationId = "io.picoclaw.android"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile", ""))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword", "")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias", "")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,16 @@
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.PicoClaw">
|
android:theme="@style/Theme.PicoClaw">
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
import io.picoclaw.android.core.data.local.AppDatabase
|
import io.picoclaw.android.core.data.local.AppDatabase
|
||||||
|
import io.picoclaw.android.core.data.local.ImageFileStorage
|
||||||
import io.picoclaw.android.core.data.remote.WebSocketClient
|
import io.picoclaw.android.core.data.remote.WebSocketClient
|
||||||
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
||||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||||
|
|
@ -51,8 +52,11 @@ val appModule = module {
|
||||||
// WebSocketClient
|
// WebSocketClient
|
||||||
single { WebSocketClient(get()) }
|
single { WebSocketClient(get()) }
|
||||||
|
|
||||||
|
// ImageFileStorage
|
||||||
|
single { ImageFileStorage(androidContext()) }
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get()) }
|
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
factory { SendMessageUseCase(get()) }
|
factory { SendMessageUseCase(get()) }
|
||||||
|
|
|
||||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
4
android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="camera_images" path="images/" />
|
||||||
|
</paths>
|
||||||
|
|
@ -6,7 +6,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.picoclaw.android.core.data"
|
namespace = "io.picoclaw.android.core.data"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import io.picoclaw.android.core.data.local.converter.Converters
|
||||||
import io.picoclaw.android.core.data.local.dao.MessageDao
|
import io.picoclaw.android.core.data.local.dao.MessageDao
|
||||||
import io.picoclaw.android.core.data.local.entity.MessageEntity
|
import io.picoclaw.android.core.data.local.entity.MessageEntity
|
||||||
|
|
||||||
@Database(entities = [MessageEntity::class], version = 1)
|
@Database(entities = [MessageEntity::class], version = 1, exportSchema = false)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun messageDao(): MessageDao
|
abstract fun messageDao(): MessageDao
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package io.picoclaw.android.core.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class ImageFileStorage(context: Context) {
|
||||||
|
|
||||||
|
private val imageDir = File(context.filesDir, "chat_images").also { it.mkdirs() }
|
||||||
|
|
||||||
|
fun saveBase64ToFile(base64: String): String {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val file = File(imageDir, "${UUID.randomUUID()}.jpg")
|
||||||
|
file.writeBytes(bytes)
|
||||||
|
return file.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ data class MessageEntity(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val sender: String,
|
val sender: String,
|
||||||
val imageBase64List: String?,
|
val imagePathList: String?,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val status: String
|
val status: String
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import java.util.UUID
|
||||||
object MessageMapper {
|
object MessageMapper {
|
||||||
|
|
||||||
fun toDomain(entity: MessageEntity): ChatMessage {
|
fun toDomain(entity: MessageEntity): ChatMessage {
|
||||||
val images = entity.imageBase64List?.let {
|
val images = entity.imagePathList?.let {
|
||||||
try {
|
try {
|
||||||
Json.decodeFromString<List<String>>(it)
|
Json.decodeFromString<List<String>>(it)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
@ -37,22 +37,22 @@ object MessageMapper {
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
content = dto.content,
|
content = dto.content,
|
||||||
sender = MessageSender.AGENT.name,
|
sender = MessageSender.AGENT.name,
|
||||||
imageBase64List = null,
|
imagePathList = null,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
status = MessageStatus.RECEIVED.name
|
status = MessageStatus.RECEIVED.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toEntity(text: String, images: List<ImageAttachment>, status: MessageStatus): MessageEntity {
|
fun toEntity(text: String, imagePaths: List<String>, status: MessageStatus): MessageEntity {
|
||||||
val imageJson = if (images.isNotEmpty()) {
|
val pathJson = if (imagePaths.isNotEmpty()) {
|
||||||
Json.encodeToString(images.map { it.base64 })
|
Json.encodeToString(imagePaths)
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
return MessageEntity(
|
return MessageEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
content = text,
|
content = text,
|
||||||
sender = MessageSender.USER.name,
|
sender = MessageSender.USER.name,
|
||||||
imageBase64List = imageJson,
|
imagePathList = pathJson,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
status = status.name
|
status = status.name
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package io.picoclaw.android.core.data.repository
|
package io.picoclaw.android.core.data.repository
|
||||||
|
|
||||||
|
import io.picoclaw.android.core.data.local.ImageFileStorage
|
||||||
import io.picoclaw.android.core.data.local.dao.MessageDao
|
import io.picoclaw.android.core.data.local.dao.MessageDao
|
||||||
import io.picoclaw.android.core.data.mapper.MessageMapper
|
import io.picoclaw.android.core.data.mapper.MessageMapper
|
||||||
import io.picoclaw.android.core.data.remote.WebSocketClient
|
import io.picoclaw.android.core.data.remote.WebSocketClient
|
||||||
|
|
@ -21,7 +22,8 @@ import kotlinx.coroutines.launch
|
||||||
class ChatRepositoryImpl(
|
class ChatRepositoryImpl(
|
||||||
private val webSocketClient: WebSocketClient,
|
private val webSocketClient: WebSocketClient,
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope,
|
||||||
|
private val imageFileStorage: ImageFileStorage
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
|
||||||
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
|
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
|
||||||
|
|
@ -31,7 +33,7 @@ class ChatRepositoryImpl(
|
||||||
_displayLimit.flatMapLatest { limit ->
|
_displayLimit.flatMapLatest { limit ->
|
||||||
messageDao.getRecentMessages(limit)
|
messageDao.getRecentMessages(limit)
|
||||||
}.map { entities ->
|
}.map { entities ->
|
||||||
entities.map { MessageMapper.toDomain(it) }.reversed()
|
entities.map { MessageMapper.toDomain(it) }
|
||||||
}.stateIn(scope, SharingStarted.Lazily, emptyList())
|
}.stateIn(scope, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
|
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
|
||||||
|
|
@ -46,7 +48,8 @@ class ChatRepositoryImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
|
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
|
||||||
val entity = MessageMapper.toEntity(text, images, MessageStatus.SENDING)
|
val imagePaths = images.map { imageFileStorage.saveBase64ToFile(it.base64) }
|
||||||
|
val entity = MessageMapper.toEntity(text, imagePaths, MessageStatus.SENDING)
|
||||||
messageDao.insert(entity)
|
messageDao.insert(entity)
|
||||||
val wsDto = MessageMapper.toWsIncoming(text, images)
|
val wsDto = MessageMapper.toWsIncoming(text, images)
|
||||||
val success = webSocketClient.send(wsDto)
|
val success = webSocketClient.send(wsDto)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.picoclaw.android.core.domain"
|
namespace = "io.picoclaw.android.core.domain"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.picoclaw.android.core.ui"
|
namespace = "io.picoclaw.android.core.ui"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.picoclaw.android.feature.chat"
|
namespace = "io.picoclaw.android.feature.chat"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
@ -39,5 +39,7 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.coroutines.android)
|
implementation(libs.coroutines.android)
|
||||||
|
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package io.picoclaw.android.feature.chat.component
|
package io.picoclaw.android.feature.chat.component
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -13,17 +10,17 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
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.ChatMessage
|
import io.picoclaw.android.core.domain.model.ChatMessage
|
||||||
import io.picoclaw.android.core.domain.model.MessageSender
|
import io.picoclaw.android.core.domain.model.MessageSender
|
||||||
import io.picoclaw.android.core.ui.theme.AgentBubble
|
import io.picoclaw.android.core.ui.theme.AgentBubble
|
||||||
import io.picoclaw.android.core.ui.theme.UserBubble
|
import io.picoclaw.android.core.ui.theme.UserBubble
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(
|
fun MessageBubble(
|
||||||
|
|
@ -52,18 +49,9 @@ fun MessageBubble(
|
||||||
modifier = Modifier.widthIn(max = 300.dp)
|
modifier = Modifier.widthIn(max = 300.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
message.images.forEach { base64 ->
|
message.images.forEach { filePath ->
|
||||||
val bitmap = remember(base64) {
|
AsyncImage(
|
||||||
try {
|
model = File(filePath),
|
||||||
val bytes = Base64.decode(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
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -71,7 +59,6 @@ fun MessageBubble(
|
||||||
contentScale = ContentScale.FillWidth
|
contentScale = ContentScale.FillWidth
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (message.content.isNotEmpty()) {
|
if (message.content.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ fun MessageInput(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterEnd
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onCameraClick) {
|
IconButton(onClick = onCameraClick) {
|
||||||
Icon(Icons.Default.CameraAlt, contentDescription = "Camera")
|
Icon(Icons.Default.CameraAlt, contentDescription = "Camera")
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ fun MessageList(
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = messages.reversed(),
|
items = messages,
|
||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { message ->
|
) { message ->
|
||||||
MessageBubble(message = message)
|
MessageBubble(message = message)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package io.picoclaw.android.feature.chat.screen
|
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
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -13,8 +18,13 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import io.picoclaw.android.core.domain.model.ImageAttachment
|
||||||
import io.picoclaw.android.feature.chat.ChatEvent
|
import io.picoclaw.android.feature.chat.ChatEvent
|
||||||
import io.picoclaw.android.feature.chat.ChatViewModel
|
import io.picoclaw.android.feature.chat.ChatViewModel
|
||||||
import io.picoclaw.android.feature.chat.component.ConnectionBanner
|
import io.picoclaw.android.feature.chat.component.ConnectionBanner
|
||||||
|
|
@ -22,15 +32,58 @@ 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
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
viewModel: ChatViewModel = koinViewModel()
|
viewModel: ChatViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.TakePicture()
|
||||||
|
) { success ->
|
||||||
|
if (success) {
|
||||||
|
cameraImageUri?.let { uri ->
|
||||||
|
uriToBase64(context, uri)?.let { base64 ->
|
||||||
|
viewModel.onEvent(
|
||||||
|
ChatEvent.OnImageAdded(
|
||||||
|
ImageAttachment(
|
||||||
|
uri = uri.toString(),
|
||||||
|
base64 = base64
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
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 shouldLoadMore by remember {
|
val shouldLoadMore by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
|
|
@ -71,9 +124,34 @@ fun ChatScreen(
|
||||||
text = uiState.inputText,
|
text = uiState.inputText,
|
||||||
onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
|
onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
|
||||||
onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
|
onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
|
||||||
onCameraClick = { /* TODO */ },
|
onCameraClick = {
|
||||||
onGalleryClick = { /* TODO */ }
|
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
|
||||||
|
val imageFile = File(imagesDir, "camera_${System.currentTimeMillis()}.jpg")
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
imageFile
|
||||||
|
)
|
||||||
|
cameraImageUri = uri
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
},
|
||||||
|
onGalleryClick = {
|
||||||
|
galleryLauncher.launch("image/*")
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
[versions]
|
[versions]
|
||||||
agp = "8.7.3"
|
agp = "9.0.1"
|
||||||
kotlin = "2.1.0"
|
kotlin = "2.3.0"
|
||||||
ksp = "2.1.0-1.0.29"
|
ksp = "2.3.6"
|
||||||
compose-bom = "2024.12.01"
|
compose-bom = "2026.01.01"
|
||||||
ktor = "3.0.3"
|
ktor = "3.4.0"
|
||||||
koin = "4.0.2"
|
koin = "4.1.1"
|
||||||
serialization = "1.7.3"
|
serialization = "1.10.0"
|
||||||
room = "2.6.1"
|
room = "2.8.4"
|
||||||
lifecycle = "2.8.7"
|
lifecycle = "2.10.0"
|
||||||
activity-compose = "1.9.3"
|
activity-compose = "1.12.4"
|
||||||
core-ktx = "1.15.0"
|
core-ktx = "1.17.0"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.10.2"
|
||||||
|
coil = "3.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose
|
# Compose
|
||||||
|
|
@ -48,6 +49,9 @@ koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", versi
|
||||||
# Coroutines
|
# Coroutines
|
||||||
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# Coil
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
577
doc/android-apk-plan.md
Normal file
577
doc/android-apk-plan.md
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
# PicoClaw Android APK - チャットUI設計プラン
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
PicoClawはTermux上で動作するGo製AIエージェント。WebSocketサーバーチャネル(`pkg/channels/websocket.go`)を通じて外部クライアントと通信可能。このWSサーバーに接続するAndroid APKを作成する。初期リリースはテキスト+画像チャットUI、将来的にボイスUI(Googleアシスタント風)を追加予定。
|
||||||
|
|
||||||
|
## WSプロトコル(確認済み)
|
||||||
|
|
||||||
|
```
|
||||||
|
Endpoint: ws://127.0.0.1:18793/ws
|
||||||
|
|
||||||
|
Client → Server: {"content":"text", "sender_id":"optional", "images":["raw_base64_no_prefix"]}
|
||||||
|
Server → Client: {"content":"text"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- imagesはraw base64(`data:`プレフィックスなし)。サーバー側で`data:image/png;base64,`を付与(websocket.go:237)
|
||||||
|
- sender_id未指定時、サーバーがUUIDを割り当て(websocket.go:227-230)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術スタック
|
||||||
|
|
||||||
|
| ツール | バージョン | 備考 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| AGP | **9.0.1** | Kotlin組み込み、`kotlin-android`プラグイン不要 |
|
||||||
|
| Gradle | **9.1+** | AGP 9.0要件 |
|
||||||
|
| Kotlin | **2.3.0** (AGP組み込み) | |
|
||||||
|
| Compose BOM | **2026.01.01** | |
|
||||||
|
| Ktor Client | **3.4.0** | OkHttpエンジン + WebSocketプラグイン |
|
||||||
|
| kotlinx.serialization | **1.10.0** | コンパイラプラグイン |
|
||||||
|
| Koin | **4.1.1** | DSLベースDI |
|
||||||
|
| Room | **2.8.4** | SQLite永続化(KSP必要) |
|
||||||
|
| KSP | **2.3.6** | Room用 |
|
||||||
|
| compileSdk / targetSdk | **36** | |
|
||||||
|
| minSdk | **28** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アーキテクチャ — マルチモジュール構成
|
||||||
|
|
||||||
|
**Jetpack Compose + MVVM + Clean Architecture + Feature Module分離**
|
||||||
|
|
||||||
|
```
|
||||||
|
:app → アプリのエントリーポイント、DI、ナビゲーション
|
||||||
|
:feature:chat → チャットUI(Compose画面、ViewModel、UIコンポーネント)
|
||||||
|
:core:domain → ドメインモデル、リポジトリinterface、UseCases
|
||||||
|
:core:data → リポジトリ実装、Room DB、WebSocketClient、DTO
|
||||||
|
:core:ui → 共有テーマ、共有コンポーネント
|
||||||
|
```
|
||||||
|
|
||||||
|
**依存関係**:
|
||||||
|
```
|
||||||
|
:app → :feature:chat, :core:domain, :core:data, :core:ui
|
||||||
|
:feature:chat → :core:domain, :core:ui
|
||||||
|
:core:data → :core:domain
|
||||||
|
:core:ui → (compose dependencies only)
|
||||||
|
```
|
||||||
|
|
||||||
|
**利点**: ボイスUI追加時は `:feature:voice` を新設するだけ。`:core:domain`と`:core:data`はそのまま共有。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ディレクトリ構成
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── .gitignore
|
||||||
|
├── build.gradle.kts # ルートビルド
|
||||||
|
├── settings.gradle.kts # 全モジュール include
|
||||||
|
├── gradle.properties
|
||||||
|
├── gradle/
|
||||||
|
│ └── libs.versions.toml
|
||||||
|
│
|
||||||
|
├── app/ # === :app モジュール ===
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ ├── proguard-rules.pro
|
||||||
|
│ └── src/main/
|
||||||
|
│ ├── AndroidManifest.xml
|
||||||
|
│ ├── res/
|
||||||
|
│ │ ├── xml/network_security_config.xml
|
||||||
|
│ │ ├── values/strings.xml
|
||||||
|
│ │ ├── values/colors.xml
|
||||||
|
│ │ └── values/themes.xml
|
||||||
|
│ └── java/io/picoclaw/android/
|
||||||
|
│ ├── PicoClawApp.kt # Application(Koin初期化)
|
||||||
|
│ ├── MainActivity.kt # Single Activity + Navigation
|
||||||
|
│ └── di/
|
||||||
|
│ └── AppModule.kt # Koin全体module定義
|
||||||
|
│
|
||||||
|
├── feature/
|
||||||
|
│ └── chat/ # === :feature:chat モジュール ===
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ └── src/main/java/io/picoclaw/android/feature/chat/
|
||||||
|
│ ├── ChatViewModel.kt
|
||||||
|
│ ├── ChatUiState.kt
|
||||||
|
│ ├── ChatEvent.kt
|
||||||
|
│ ├── screen/
|
||||||
|
│ │ └── ChatScreen.kt
|
||||||
|
│ └── component/
|
||||||
|
│ ├── MessageBubble.kt
|
||||||
|
│ ├── MessageInput.kt
|
||||||
|
│ ├── ConnectionBanner.kt
|
||||||
|
│ ├── ImagePreview.kt
|
||||||
|
│ └── MessageList.kt
|
||||||
|
│
|
||||||
|
├── core/
|
||||||
|
│ ├── domain/ # === :core:domain モジュール ===
|
||||||
|
│ │ ├── build.gradle.kts
|
||||||
|
│ │ └── src/main/java/io/picoclaw/android/core/domain/
|
||||||
|
│ │ ├── model/
|
||||||
|
│ │ │ ├── ChatMessage.kt
|
||||||
|
│ │ │ ├── ConnectionState.kt
|
||||||
|
│ │ │ └── ImageAttachment.kt
|
||||||
|
│ │ ├── repository/
|
||||||
|
│ │ │ └── ChatRepository.kt # interface
|
||||||
|
│ │ └── usecase/
|
||||||
|
│ │ ├── SendMessageUseCase.kt
|
||||||
|
│ │ ├── ObserveMessagesUseCase.kt
|
||||||
|
│ │ ├── ObserveConnectionUseCase.kt
|
||||||
|
│ │ └── LoadMoreMessagesUseCase.kt
|
||||||
|
│ │
|
||||||
|
│ ├── data/ # === :core:data モジュール ===
|
||||||
|
│ │ ├── build.gradle.kts
|
||||||
|
│ │ └── src/main/java/io/picoclaw/android/core/data/
|
||||||
|
│ │ ├── remote/
|
||||||
|
│ │ │ ├── WebSocketClient.kt # Ktor WS + auto-reconnect
|
||||||
|
│ │ │ └── dto/
|
||||||
|
│ │ │ ├── WsIncoming.kt # @Serializable
|
||||||
|
│ │ │ └── WsOutgoing.kt # @Serializable
|
||||||
|
│ │ ├── local/
|
||||||
|
│ │ │ ├── AppDatabase.kt # Room Database
|
||||||
|
│ │ │ ├── entity/
|
||||||
|
│ │ │ │ └── MessageEntity.kt # Room Entity
|
||||||
|
│ │ │ ├── dao/
|
||||||
|
│ │ │ │ └── MessageDao.kt # Room DAO
|
||||||
|
│ │ │ └── converter/
|
||||||
|
│ │ │ └── Converters.kt # TypeConverter
|
||||||
|
│ │ ├── mapper/
|
||||||
|
│ │ │ └── MessageMapper.kt # Entity ↔ Domain, DTO ↔ Entity
|
||||||
|
│ │ └── repository/
|
||||||
|
│ │ └── ChatRepositoryImpl.kt
|
||||||
|
│ │
|
||||||
|
│ └── ui/ # === :core:ui モジュール ===
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ └── src/main/java/io/picoclaw/android/core/ui/
|
||||||
|
│ └── theme/
|
||||||
|
│ ├── Theme.kt
|
||||||
|
│ ├── Color.kt
|
||||||
|
│ └── Type.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各層の設計
|
||||||
|
|
||||||
|
### :core:data — Data層
|
||||||
|
|
||||||
|
#### Room DB(メッセージ永続化)
|
||||||
|
|
||||||
|
**MessageEntity**:
|
||||||
|
```kotlin
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String, // UUID
|
||||||
|
val content: String,
|
||||||
|
val sender: String, // "USER" or "AGENT"
|
||||||
|
val imageBase64List: String?, // JSON文字列 ["base64_1","base64_2"]
|
||||||
|
val timestamp: Long, // epoch millis
|
||||||
|
val status: String // "SENDING","SENT","FAILED","RECEIVED"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**MessageDao**:
|
||||||
|
```kotlin
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
@Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit")
|
||||||
|
fun getRecentMessages(limit: Int): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit")
|
||||||
|
suspend fun getMessagesBefore(beforeTimestamp: Long, limit: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(message: MessageEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(message: MessageEntity)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AppDatabase**:
|
||||||
|
```kotlin
|
||||||
|
@Database(entities = [MessageEntity::class], version = 1)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ページネーション戦略
|
||||||
|
|
||||||
|
```
|
||||||
|
起動時:
|
||||||
|
→ DAO.getRecentMessages(limit=50) をFlowでobserve
|
||||||
|
→ 最新50件が表示される → 新メッセージはinsert → Flowが自動更新
|
||||||
|
|
||||||
|
スクロール遡り:
|
||||||
|
→ LazyColumnの先頭付近に到達を検知
|
||||||
|
→ Repository.loadMore() → _displayLimitを+30
|
||||||
|
→ RoomのFlowが自動的に再クエリ → UIに反映
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WebSocketClient — Ktor Client (OkHttpエンジン) ベース
|
||||||
|
- `connectionState: StateFlow<ConnectionState>` で接続状態を公開
|
||||||
|
- `incomingMessages: SharedFlow<WsOutgoing>` でサーバー応答を公開
|
||||||
|
- `connect()` / `disconnect()` / `send(dto)` メソッド
|
||||||
|
- exponential backoff自動再接続(1s→2s→4s→8s→max 30s)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val client = HttpClient(OkHttp) {
|
||||||
|
install(WebSockets)
|
||||||
|
engine {
|
||||||
|
preconfigured = OkHttpClient.Builder()
|
||||||
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ChatRepositoryImpl — Room + WS統合
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ChatRepositoryImpl(
|
||||||
|
private val webSocketClient: WebSocketClient,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) : ChatRepository {
|
||||||
|
|
||||||
|
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT) // 50
|
||||||
|
|
||||||
|
override val messages: StateFlow<List<ChatMessage>> =
|
||||||
|
_displayLimit.flatMapLatest { limit ->
|
||||||
|
messageDao.getRecentMessages(limit)
|
||||||
|
}.map { entities ->
|
||||||
|
entities.map { MessageMapper.toDomain(it) }.reversed()
|
||||||
|
}.stateIn(scope, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
|
override val connectionState = webSocketClient.connectionState
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
webSocketClient.incomingMessages.collect { dto ->
|
||||||
|
val entity = MessageMapper.toEntity(dto)
|
||||||
|
messageDao.insert(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
|
||||||
|
val entity = MessageMapper.toEntity(text, images, MessageStatus.SENDING)
|
||||||
|
messageDao.insert(entity)
|
||||||
|
val wsDto = MessageMapper.toWsIncoming(text, images)
|
||||||
|
val success = webSocketClient.send(wsDto)
|
||||||
|
messageDao.update(entity.copy(status = if (success) "SENT" else "FAILED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadMore() {
|
||||||
|
_displayLimit.update { it + PAGE_SIZE }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INITIAL_LOAD_COUNT = 50
|
||||||
|
const val PAGE_SIZE = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DTO — `@Serializable`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data class WsIncoming(
|
||||||
|
val content: String,
|
||||||
|
@SerialName("sender_id") val senderId: String? = null,
|
||||||
|
val images: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WsOutgoing(val content: String)
|
||||||
|
```
|
||||||
|
|
||||||
|
### :core:domain — Domain層
|
||||||
|
|
||||||
|
**ChatMessage** — `id, content, sender(USER/AGENT), images: List<String>, timestamp, status`
|
||||||
|
|
||||||
|
**ConnectionState** — `DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING`
|
||||||
|
|
||||||
|
**ImageAttachment** — `uri, base64, mimeType`
|
||||||
|
|
||||||
|
**ChatRepository interface**:
|
||||||
|
```kotlin
|
||||||
|
interface ChatRepository {
|
||||||
|
val messages: StateFlow<List<ChatMessage>>
|
||||||
|
val connectionState: StateFlow<ConnectionState>
|
||||||
|
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
|
||||||
|
fun loadMore()
|
||||||
|
fun connect()
|
||||||
|
fun disconnect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UseCases** — `SendMessageUseCase`, `ObserveMessagesUseCase`, `ObserveConnectionUseCase`, `LoadMoreMessagesUseCase`
|
||||||
|
|
||||||
|
### :core:ui — 共有UI
|
||||||
|
|
||||||
|
テーマ定義(Color, Type, Theme)。将来的に共有コンポーネントもここに配置。
|
||||||
|
|
||||||
|
### :feature:chat — チャットUI
|
||||||
|
|
||||||
|
**ChatUiState**:
|
||||||
|
```kotlin
|
||||||
|
data class ChatUiState(
|
||||||
|
val messages: List<ChatMessage> = emptyList(),
|
||||||
|
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||||
|
val inputText: String = "",
|
||||||
|
val pendingImages: List<ImageAttachment> = emptyList(),
|
||||||
|
val isLoadingMore: Boolean = false,
|
||||||
|
val canLoadMore: Boolean = true,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**ChatViewModel** — Koin `koinViewModel()`で注入
|
||||||
|
|
||||||
|
**Compose UI階層**:
|
||||||
|
```
|
||||||
|
ChatScreen
|
||||||
|
├── ConnectionBanner # 接続状態(赤/黄/非表示)
|
||||||
|
├── MessageList (weight 1f) # LazyColumn (reverseLayout=true)
|
||||||
|
│ ├── Loading indicator # isLoadingMore時
|
||||||
|
│ └── MessageBubble # 右=ユーザー、左=エージェント
|
||||||
|
└── Bottom Column
|
||||||
|
├── ImagePreviewRow # 送信前サムネイル
|
||||||
|
└── MessageInput # [カメラ][ギャラリー][TextField][送信]
|
||||||
|
```
|
||||||
|
|
||||||
|
**スクロール検知でloadMore**:
|
||||||
|
```kotlin
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
|
lastVisibleItem != null &&
|
||||||
|
lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) viewModel.onLoadMore()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DI(Koin)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// app/di/AppModule.kt
|
||||||
|
val appModule = module {
|
||||||
|
// Room
|
||||||
|
single {
|
||||||
|
Room.databaseBuilder(androidContext(), AppDatabase::class.java, "picoclaw.db").build()
|
||||||
|
}
|
||||||
|
single { get<AppDatabase>().messageDao() }
|
||||||
|
|
||||||
|
// Ktor HttpClient
|
||||||
|
single {
|
||||||
|
HttpClient(OkHttp) {
|
||||||
|
install(WebSockets)
|
||||||
|
engine {
|
||||||
|
preconfigured = OkHttpClient.Builder()
|
||||||
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketClient
|
||||||
|
single { WebSocketClient(get()) }
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get()) }
|
||||||
|
|
||||||
|
// UseCases
|
||||||
|
factory { SendMessageUseCase(get()) }
|
||||||
|
factory { ObserveMessagesUseCase(get()) }
|
||||||
|
factory { ObserveConnectionUseCase(get()) }
|
||||||
|
factory { LoadMoreMessagesUseCase(get()) }
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
viewModel { ChatViewModel(get(), get(), get(), get(), get()) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gradle設定
|
||||||
|
|
||||||
|
### settings.gradle.kts
|
||||||
|
```kotlin
|
||||||
|
pluginManagement {
|
||||||
|
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories { google(); mavenCentral() }
|
||||||
|
}
|
||||||
|
rootProject.name = "PicoClaw"
|
||||||
|
include(":app")
|
||||||
|
include(":feature:chat")
|
||||||
|
include(":core:domain")
|
||||||
|
include(":core:data")
|
||||||
|
include(":core:ui")
|
||||||
|
```
|
||||||
|
|
||||||
|
### gradle/libs.versions.toml
|
||||||
|
```toml
|
||||||
|
[versions]
|
||||||
|
agp = "9.0.1"
|
||||||
|
kotlin = "2.3.0"
|
||||||
|
ksp = "2.3.6"
|
||||||
|
compose-bom = "2026.01.01"
|
||||||
|
ktor = "3.4.0"
|
||||||
|
koin = "4.1.1"
|
||||||
|
serialization = "1.10.0"
|
||||||
|
room = "2.8.4"
|
||||||
|
lifecycle = "2.10.0"
|
||||||
|
activity-compose = "1.12.4"
|
||||||
|
core-ktx = "1.17.0"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
# Compose
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
# AndroidX
|
||||||
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||||
|
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
||||||
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
|
||||||
|
# Ktor
|
||||||
|
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
# Koin
|
||||||
|
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||||
|
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各モジュールのbuild.gradle.kts概要
|
||||||
|
|
||||||
|
**:app** — `android-application`, `serialization` → depends on all modules
|
||||||
|
**:feature:chat** — `android-library` → depends on `:core:domain`, `:core:ui`
|
||||||
|
**:core:domain** — `android-library` → depends on nothing (pure Kotlin + coroutines)
|
||||||
|
**:core:data** — `android-library`, `serialization`, `ksp` → depends on `:core:domain` (Room, Ktor, DTO)
|
||||||
|
**:core:ui** — `android-library` → depends on nothing (Compose theme only)
|
||||||
|
|
||||||
|
KSPは`:core:data`モジュールのみで使用(Room compiler)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意点
|
||||||
|
|
||||||
|
- **Cleartext通信**: `ws://127.0.0.1`への通信に`network_security_config.xml`でlocalhost/127.0.0.1のみ許可
|
||||||
|
- **画像base64**: サーバーが`data:image/png;base64,`を自動付与するため、クライアントはraw base64のみ送信
|
||||||
|
- **画像のDB保存**: 画像base64はMessageEntityにJSON配列文字列として保存。TypeConverterで変換
|
||||||
|
- **Ktor WS ping**: OkHttpエンジン使用時はOkHttpBuilder側の`pingInterval`で設定
|
||||||
|
- **AGP 9.0**: `kotlin-android`プラグイン不適用、`kotlin.compilerOptions`を使用
|
||||||
|
- **マルチモジュール**: 各library moduleは`android.namespace`を個別設定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 実装順序
|
||||||
|
|
||||||
|
### Phase 1: プロジェクトスキャフォールド + マルチモジュール
|
||||||
|
1. `android/` ディレクトリ構造(5モジュール分)
|
||||||
|
2. ルート `build.gradle.kts`、`settings.gradle.kts`(全module include)、`libs.versions.toml`
|
||||||
|
3. `gradle.properties`
|
||||||
|
4. 各モジュールの `build.gradle.kts`
|
||||||
|
5. `app/` の `AndroidManifest.xml` + `network_security_config.xml` + リソース
|
||||||
|
6. Gradleラッパー生成
|
||||||
|
|
||||||
|
### Phase 2: :core:domain
|
||||||
|
7. `ConnectionState.kt`、`ChatMessage.kt`、`ImageAttachment.kt`
|
||||||
|
8. `ChatRepository.kt` (interface)
|
||||||
|
9. 4つのUseCase
|
||||||
|
|
||||||
|
### Phase 3: :core:ui
|
||||||
|
10. Theme(Color.kt, Type.kt, Theme.kt)
|
||||||
|
|
||||||
|
### Phase 4: :core:data — Room
|
||||||
|
11. `MessageEntity.kt` + `Converters.kt`
|
||||||
|
12. `MessageDao.kt`
|
||||||
|
13. `AppDatabase.kt`
|
||||||
|
|
||||||
|
### Phase 5: :core:data — WebSocket + Repository
|
||||||
|
14. `WsIncoming.kt`、`WsOutgoing.kt` (@Serializable)
|
||||||
|
15. `MessageMapper.kt`
|
||||||
|
16. `WebSocketClient.kt`(Ktor + auto-reconnect)
|
||||||
|
17. `ChatRepositoryImpl.kt`(Room + WS統合、ページネーション)
|
||||||
|
|
||||||
|
### Phase 6: :feature:chat
|
||||||
|
18. `ChatUiState.kt`、`ChatEvent.kt`
|
||||||
|
19. `ChatViewModel.kt`(loadMore対応)
|
||||||
|
20. UIコンポーネント(ConnectionBanner, MessageBubble, MessageList, ImagePreview, MessageInput)
|
||||||
|
21. `ChatScreen.kt`(スクロール検知+ページネーション)
|
||||||
|
|
||||||
|
### Phase 7: :app
|
||||||
|
22. `AppModule.kt`(Koin全体module)
|
||||||
|
23. `PicoClawApp.kt`
|
||||||
|
24. `MainActivity.kt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 検証方法
|
||||||
|
|
||||||
|
1. Termuxでpicoclaw起動(WSサーバーが`127.0.0.1:18793`でリスン)
|
||||||
|
2. APKをビルド・インストール(`./gradlew assembleDebug`)
|
||||||
|
3. アプリ起動 → ConnectionBannerが「Connected」表示を確認
|
||||||
|
4. テキスト送信 → エージェントからの応答がチャットに表示されることを確認
|
||||||
|
5. アプリを再起動 → 前回のチャット履歴がRoomから読み込まれて表示されることを確認
|
||||||
|
6. 50件以上メッセージを蓄積 → 上にスクロール → 古いメッセージが動的にロードされることを確認
|
||||||
|
7. カメラ/ギャラリーから画像添付+テキスト送信 → エージェントが画像を認識した応答を返すことを確認
|
||||||
|
8. WSサーバーを停止 → 「Reconnecting」表示 → サーバー再起動後に自動再接続を確認
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 将来の拡張
|
||||||
|
|
||||||
|
- **:feature:voice**: `:core:domain`と`:core:data`をそのまま共有。SpeechRecognizer+TextToSpeechのUI層のみ新設
|
||||||
|
- **複数サーバー**: `WebSocketClient`のurl引数を動的に
|
||||||
|
- **メッセージ検索**: Room DAOにFTS4クエリ追加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参照するサーバー側ファイル
|
||||||
|
|
||||||
|
| ファイル | 用途 |
|
||||||
|
|---------|------|
|
||||||
|
| `pkg/channels/websocket.go` | WSプロトコル定義 |
|
||||||
|
| `pkg/bus/types.go` | InboundMessage/OutboundMessage構造体 |
|
||||||
|
| `pkg/channels/base.go` | Channelインターフェース |
|
||||||
|
| `pkg/config/config.go` | WebSocketConfig |
|
||||||
Loading…
Add table
Reference in a new issue