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