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:
Kohei 2026-02-18 20:51:23 +09:00
parent 3eb99d9d1a
commit 862e47823d
21 changed files with 770 additions and 59 deletions

2
android/.gitignore vendored
View file

@ -11,3 +11,5 @@ local.properties
/app/build /app/build
/feature/*/build /feature/*/build
/core/*/build /core/*/build
keystore.properties
keystore/

View file

@ -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"

View file

@ -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"

View file

@ -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()) }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera_images" path="images/" />
</paths>

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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
) )

View file

@ -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
) )

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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,

View file

@ -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")

View file

@ -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)

View file

@ -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
}
}

View file

@ -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" }

View file

@ -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
View file

@ -0,0 +1,577 @@
# PicoClaw Android APK - チャットUI設計プラン
## Context
PicoClawはTermux上で動作するGo製AIエージェント。WebSocketサーバーチャネル(`pkg/channels/websocket.go`)を通じて外部クライアントと通信可能。このWSサーバーに接続するAndroid APKを作成する。初期リリースはテキスト+画像チャットUI、将来的にボイスUIGoogleアシスタント風を追加予定。
## 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 → チャットUICompose画面、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 # ApplicationKoin初期化
│ ├── 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()
}
```
---
## DIKoin
```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. ThemeColor.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 |