From 862e47823dcb6bfd6c4edd6d2896ff3134e7156c Mon Sep 17 00:00:00 2001 From: Kohei Date: Wed, 18 Feb 2026 20:51:23 +0900 Subject: [PATCH] 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 --- android/.gitignore | 2 + android/app/build.gradle.kts | 26 +- android/app/src/main/AndroidManifest.xml | 10 + .../java/io/picoclaw/android/di/AppModule.kt | 6 +- android/app/src/main/res/xml/file_paths.xml | 4 + android/core/data/build.gradle.kts | 2 +- .../android/core/data/local/AppDatabase.kt | 2 +- .../core/data/local/ImageFileStorage.kt | 18 + .../core/data/local/entity/MessageEntity.kt | 2 +- .../android/core/data/mapper/MessageMapper.kt | 12 +- .../data/repository/ChatRepositoryImpl.kt | 9 +- android/core/domain/build.gradle.kts | 2 +- android/core/ui/build.gradle.kts | 2 +- android/feature/chat/build.gradle.kts | 4 +- .../feature/chat/component/MessageBubble.kt | 35 +- .../feature/chat/component/MessageInput.kt | 2 +- .../feature/chat/component/MessageList.kt | 2 +- .../android/feature/chat/screen/ChatScreen.kt | 82 ++- android/gradle/libs.versions.toml | 28 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- doc/android-apk-plan.md | 577 ++++++++++++++++++ 21 files changed, 770 insertions(+), 59 deletions(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt create mode 100644 doc/android-apk-plan.md diff --git a/android/.gitignore b/android/.gitignore index 56ce17a7a..5e18b2936 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,5 @@ local.properties /app/build /feature/*/build /core/*/build +keystore.properties +keystore/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9149c4fd0..0c5b53bef 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,24 +1,46 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.serialization) } +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { load(it) } + } +} + android { namespace = "io.picoclaw.android" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "io.picoclaw.android" minSdk = 28 - targetSdk = 35 + targetSdk = 36 versionCode = 1 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 { + debug { + signingConfig = signingConfigs.getByName("release") + } release { isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6a6ba1bf9..95a13792b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,16 @@ android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true" android:theme="@style/Theme.PicoClaw"> + + + + { ChatRepositoryImpl(get(), get(), get()) } + single { ChatRepositoryImpl(get(), get(), get(), get()) } // UseCases factory { SendMessageUseCase(get()) } diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..7924efa5d --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/core/data/build.gradle.kts b/android/core/data/build.gradle.kts index b422394cb..95c369752 100644 --- a/android/core/data/build.gradle.kts +++ b/android/core/data/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "io.picoclaw.android.core.data" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 28 diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt index 65c104f7b..2883e1064 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt @@ -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.entity.MessageEntity -@Database(entities = [MessageEntity::class], version = 1) +@Database(entities = [MessageEntity::class], version = 1, exportSchema = false) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao 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 new file mode 100644 index 000000000..9fcacfa5a --- /dev/null +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt @@ -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 + } +} diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt index dd9978530..a722a6ad8 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt @@ -8,7 +8,7 @@ data class MessageEntity( @PrimaryKey val id: String, val content: String, val sender: String, - val imageBase64List: String?, + val imagePathList: String?, val timestamp: Long, val status: String ) 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 b2fc601d6..508ed1873 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 @@ -14,7 +14,7 @@ import java.util.UUID object MessageMapper { fun toDomain(entity: MessageEntity): ChatMessage { - val images = entity.imageBase64List?.let { + val images = entity.imagePathList?.let { try { Json.decodeFromString>(it) } catch (_: Exception) { @@ -37,22 +37,22 @@ object MessageMapper { id = UUID.randomUUID().toString(), content = dto.content, sender = MessageSender.AGENT.name, - imageBase64List = null, + imagePathList = null, timestamp = System.currentTimeMillis(), status = MessageStatus.RECEIVED.name ) } - fun toEntity(text: String, images: List, status: MessageStatus): MessageEntity { - val imageJson = if (images.isNotEmpty()) { - Json.encodeToString(images.map { it.base64 }) + fun toEntity(text: String, imagePaths: List, status: MessageStatus): MessageEntity { + val pathJson = if (imagePaths.isNotEmpty()) { + Json.encodeToString(imagePaths) } else null return MessageEntity( id = UUID.randomUUID().toString(), content = text, sender = MessageSender.USER.name, - imageBase64List = imageJson, + imagePathList = pathJson, timestamp = System.currentTimeMillis(), status = status.name ) 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 4f39b0b5f..cbc7088e2 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 @@ -1,5 +1,6 @@ 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.mapper.MessageMapper import io.picoclaw.android.core.data.remote.WebSocketClient @@ -21,7 +22,8 @@ import kotlinx.coroutines.launch class ChatRepositoryImpl( private val webSocketClient: WebSocketClient, private val messageDao: MessageDao, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val imageFileStorage: ImageFileStorage ) : ChatRepository { private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT) @@ -31,7 +33,7 @@ class ChatRepositoryImpl( _displayLimit.flatMapLatest { limit -> messageDao.getRecentMessages(limit) }.map { entities -> - entities.map { MessageMapper.toDomain(it) }.reversed() + entities.map { MessageMapper.toDomain(it) } }.stateIn(scope, SharingStarted.Lazily, emptyList()) override val connectionState: StateFlow = webSocketClient.connectionState @@ -46,7 +48,8 @@ class ChatRepositoryImpl( } override suspend fun sendMessage(text: String, images: List) { - 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) val wsDto = MessageMapper.toWsIncoming(text, images) val success = webSocketClient.send(wsDto) diff --git a/android/core/domain/build.gradle.kts b/android/core/domain/build.gradle.kts index 3f8621fe9..2a0c4cd39 100644 --- a/android/core/domain/build.gradle.kts +++ b/android/core/domain/build.gradle.kts @@ -4,7 +4,7 @@ plugins { android { namespace = "io.picoclaw.android.core.domain" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 28 diff --git a/android/core/ui/build.gradle.kts b/android/core/ui/build.gradle.kts index 6c66f5c02..208763759 100644 --- a/android/core/ui/build.gradle.kts +++ b/android/core/ui/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "io.picoclaw.android.core.ui" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 28 diff --git a/android/feature/chat/build.gradle.kts b/android/feature/chat/build.gradle.kts index 051642bc0..1651326b9 100644 --- a/android/feature/chat/build.gradle.kts +++ b/android/feature/chat/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "io.picoclaw.android.feature.chat" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 28 @@ -39,5 +39,7 @@ dependencies { implementation(libs.coroutines.android) + implementation(libs.coil.compose) + debugImplementation(libs.compose.ui.tooling) } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt index a0ff34171..c6b56c457 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt @@ -1,8 +1,5 @@ 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.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -13,17 +10,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -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.ChatMessage import io.picoclaw.android.core.domain.model.MessageSender import io.picoclaw.android.core.ui.theme.AgentBubble import io.picoclaw.android.core.ui.theme.UserBubble +import java.io.File @Composable fun MessageBubble( @@ -52,25 +49,15 @@ fun MessageBubble( modifier = Modifier.widthIn(max = 300.dp) ) { Column(modifier = Modifier.padding(12.dp)) { - message.images.forEach { base64 -> - val bitmap = remember(base64) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() - } catch (_: Exception) { - null - } - } - bitmap?.let { - Image( - bitmap = it, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - contentScale = ContentScale.FillWidth - ) - } + message.images.forEach { filePath -> + AsyncImage( + model = File(filePath), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + contentScale = ContentScale.FillWidth + ) } if (message.content.isNotEmpty()) { Text( diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt index 21e45eca1..e522687b9 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt @@ -34,7 +34,7 @@ fun MessageInput( modifier = modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterEnd + verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onCameraClick) { Icon(Icons.Default.CameraAlt, contentDescription = "Camera") diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt index c076daa12..4ecbd7baa 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt @@ -29,7 +29,7 @@ fun MessageList( contentPadding = PaddingValues(vertical = 8.dp) ) { items( - items = messages.reversed(), + items = messages, key = { it.id } ) { message -> MessageBubble(message = message) 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 59c7adb2e..b474c3d75 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,5 +1,10 @@ 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.fillMaxSize import androidx.compose.foundation.layout.padding @@ -13,8 +18,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.ChatViewModel 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.MessageList import org.koin.androidx.compose.koinViewModel +import java.io.ByteArrayOutputStream +import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel = koinViewModel() ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() val listState = rememberLazyListState() + var cameraImageUri by remember { mutableStateOf(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 { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() @@ -71,9 +124,34 @@ fun ChatScreen( text = uiState.inputText, onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) }, onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) }, - onCameraClick = { /* TODO */ }, - onGalleryClick = { /* TODO */ } + onCameraClick = { + 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 + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 993af4460..4ae731a92 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,16 +1,17 @@ [versions] -agp = "8.7.3" -kotlin = "2.1.0" -ksp = "2.1.0-1.0.29" -compose-bom = "2024.12.01" -ktor = "3.0.3" -koin = "4.0.2" -serialization = "1.7.3" -room = "2.6.1" -lifecycle = "2.8.7" -activity-compose = "1.9.3" -core-ktx = "1.15.0" -coroutines = "1.9.0" +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" +coil = "3.1.0" [libraries] # Compose @@ -48,6 +49,9 @@ koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", versi # 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] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e2847c820..37f78a6af 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME 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 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/doc/android-apk-plan.md b/doc/android-apk-plan.md new file mode 100644 index 000000000..d4ae6c778 --- /dev/null +++ b/doc/android-apk-plan.md @@ -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> + + @Query("SELECT * FROM messages WHERE timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit") + suspend fun getMessagesBefore(beforeTimestamp: Long, limit: Int): List + + @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` で接続状態を公開 +- `incomingMessages: SharedFlow` でサーバー応答を公開 +- `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> = + _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) { + 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? = null +) + +@Serializable +data class WsOutgoing(val content: String) +``` + +### :core:domain — Domain層 + +**ChatMessage** — `id, content, sender(USER/AGENT), images: List, timestamp, status` + +**ConnectionState** — `DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING` + +**ImageAttachment** — `uri, base64, mimeType` + +**ChatRepository interface**: +```kotlin +interface ChatRepository { + val messages: StateFlow> + val connectionState: StateFlow + suspend fun sendMessage(text: String, images: List = 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 = emptyList(), + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val inputText: String = "", + val pendingImages: List = 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().messageDao() } + + // Ktor HttpClient + single { + HttpClient(OkHttp) { + install(WebSockets) + engine { + preconfigured = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + } + } + } + + // WebSocketClient + single { WebSocketClient(get()) } + + // Repository + single { 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 |