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 |