diff --git a/android/app/src/main/java/io/clawdroid/MainActivity.kt b/android/app/src/main/java/io/clawdroid/MainActivity.kt index 26a7519dc..77fb77c56 100644 --- a/android/app/src/main/java/io/clawdroid/MainActivity.kt +++ b/android/app/src/main/java/io/clawdroid/MainActivity.kt @@ -9,13 +9,18 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import io.clawdroid.backend.config.ConfigSectionDetailScreen +import io.clawdroid.backend.config.ConfigSectionListScreen import io.clawdroid.core.ui.theme.ClawDroidTheme import io.clawdroid.feature.chat.screen.ChatScreen import io.clawdroid.feature.chat.screen.SettingsScreen import io.clawdroid.navigation.NavRoutes +import io.clawdroid.settings.AppSettingsScreen class MainActivity : ComponentActivity() { @@ -38,7 +43,31 @@ class MainActivity : ComponentActivity() { } composable(NavRoutes.SETTINGS) { SettingsScreen( - onNavigateBack = { navController.popBackStack() } + onNavigateBack = { navController.popBackStack() }, + onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) }, + onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) }, + ) + } + composable(NavRoutes.BACKEND_SETTINGS) { + ConfigSectionListScreen( + onNavigateBack = { navController.popBackStack() }, + onSectionSelected = { sectionKey -> + navController.navigate("backend_settings/$sectionKey") + }, + ) + } + composable( + NavRoutes.BACKEND_SETTINGS_SECTION, + arguments = listOf(navArgument("sectionKey") { type = NavType.StringType }), + ) { backStackEntry -> + ConfigSectionDetailScreen( + sectionKey = backStackEntry.arguments?.getString("sectionKey") ?: "", + onNavigateBack = { navController.popBackStack() }, + ) + } + composable(NavRoutes.APP_SETTINGS) { + AppSettingsScreen( + onNavigateBack = { navController.popBackStack() }, ) } } diff --git a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt index bcc48c266..cc3180b0c 100644 --- a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt +++ b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt @@ -3,4 +3,7 @@ package io.clawdroid.navigation object NavRoutes { const val CHAT = "chat" const val SETTINGS = "settings" + const val BACKEND_SETTINGS = "backend_settings" + const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" + const val APP_SETTINGS = "app_settings" } diff --git a/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt b/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt new file mode 100644 index 000000000..3c14cf7b5 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt @@ -0,0 +1,195 @@ +package io.clawdroid.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.R as LucideR +import io.clawdroid.core.ui.theme.DeepBlack +import io.clawdroid.core.ui.theme.GlassBorder +import io.clawdroid.core.ui.theme.GlassWhite +import io.clawdroid.core.ui.theme.GradientCyan +import io.clawdroid.core.ui.theme.GradientPurple +import io.clawdroid.core.ui.theme.NeonCyan +import io.clawdroid.core.ui.theme.TextPrimary +import io.clawdroid.core.ui.theme.TextSecondary +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSettingsScreen( + onNavigateBack: () -> Unit, + viewModel: AppSettingsViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + var apiKeyHidden by remember { mutableStateOf(true) } + val saveEnabled by remember { derivedStateOf { !uiState.hasErrors } } + + Box( + modifier = Modifier + .fillMaxSize() + .background(DeepBlack) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientCyan.copy(alpha = 0.07f), + Color.Transparent, + ), + center = Offset(size.width * 0.15f, size.height * 0.1f), + radius = size.width * 0.8f, + ), + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPurple.copy(alpha = 0.07f), + Color.Transparent, + ), + center = Offset(size.width * 0.85f, size.height * 0.9f), + radius = size.width * 0.7f, + ), + ) + }, + ) { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text("App Settings") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_arrow_left), + contentDescription = "Back", + tint = TextSecondary, + ) + } + }, + actions = { + Button( + onClick = { viewModel.save(onNavigateBack) }, + enabled = saveEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + modifier = Modifier.padding(end = 8.dp), + ) { + Text("Save") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Gateway Connection", + style = MaterialTheme.typography.titleMedium, + color = NeonCyan, + ) + + OutlinedTextField( + value = uiState.apiKey, + onValueChange = { viewModel.onApiKeyChange(it) }, + label = { Text("Gateway API Key", color = TextSecondary) }, + singleLine = true, + visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + TextButton(onClick = { apiKeyHidden = !apiKeyHidden }) { + Text( + if (apiKeyHidden) "Show" else "Hide", + color = NeonCyan, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + colors = appSettingsFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.wsPort, + onValueChange = { viewModel.onWsPortChange(it) }, + label = { Text("Gateway WS Port", color = TextSecondary) }, + placeholder = { Text("18793", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + isError = uiState.wsPortError != null, + supportingText = uiState.wsPortError?.let { err -> { Text(err) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = appSettingsFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.httpPort, + onValueChange = { viewModel.onHttpPortChange(it) }, + label = { Text("Gateway HTTP Port", color = TextSecondary) }, + placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + isError = uiState.httpPortError != null, + supportingText = uiState.httpPortError?.let { err -> { Text(err) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = appSettingsFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun appSettingsFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NeonCyan.copy(alpha = 0.5f), + unfocusedBorderColor = GlassBorder, + focusedContainerColor = GlassWhite, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, +) diff --git a/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt new file mode 100644 index 000000000..48675b66b --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt @@ -0,0 +1,79 @@ +package io.clawdroid.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.clawdroid.backend.api.GatewaySettings +import io.clawdroid.backend.api.GatewaySettingsStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AppSettingsUiState( + val apiKey: String = "", + val wsPort: String = "18793", + val httpPort: String = "18790", +) { + val wsPortError: String? get() = portError(wsPort) + val httpPortError: String? get() = portError(httpPort) + val hasErrors: Boolean get() = wsPortError != null || httpPortError != null +} + +private fun portError(value: String): String? { + if (value.isEmpty()) return null + val port = value.toIntOrNull() ?: return "Invalid number" + return if (port !in 1..65535) "1-65535" else null +} + +class AppSettingsViewModel( + private val settingsStore: GatewaySettingsStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(AppSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + val current = settingsStore.settings.value + _uiState.value = AppSettingsUiState( + apiKey = current.apiKey, + wsPort = current.wsPort.toString(), + httpPort = current.httpPort.toString(), + ) + } + + fun onApiKeyChange(value: String) { + _uiState.update { it.copy(apiKey = value) } + } + + fun onWsPortChange(value: String) { + if (value.isEmpty() || value.toIntOrNull() != null) { + _uiState.update { it.copy(wsPort = value) } + } + } + + fun onHttpPortChange(value: String) { + if (value.isEmpty() || value.toIntOrNull() != null) { + _uiState.update { it.copy(httpPort = value) } + } + } + + fun save(onComplete: () -> Unit) { + viewModelScope.launch { + val state = _uiState.value + if (state.hasErrors) return@launch + val defaults = GatewaySettings() + fun validPort(raw: String, fallback: Int): Int { + val port = raw.toIntOrNull() ?: return fallback + return if (port in 1..65535) port else fallback + } + val settings = GatewaySettings( + wsPort = validPort(state.wsPort, defaults.wsPort), + httpPort = validPort(state.httpPort, defaults.httpPort), + apiKey = state.apiKey, + ) + settingsStore.update(settings) + onComplete() + } + } +} diff --git a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/SettingsScreen.kt b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/SettingsScreen.kt index c5b5afeeb..be2bee65b 100644 --- a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/SettingsScreen.kt +++ b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/SettingsScreen.kt @@ -1,13 +1,20 @@ package io.clawdroid.feature.chat.screen import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenuItem @@ -59,6 +66,8 @@ import org.koin.androidx.compose.koinViewModel @Composable fun SettingsScreen( onNavigateBack: () -> Unit, + onNavigateToBackendSettings: () -> Unit, + onNavigateToAppSettings: () -> Unit, viewModel: SettingsViewModel = koinViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -114,7 +123,8 @@ fun SettingsScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( @@ -159,6 +169,26 @@ fun SettingsScreen( ) { Text(if (uiState.isTesting) "Speaking..." else "Test Voice") } + + Spacer(Modifier.height(8.dp)) + + Text( + "Other Settings", + style = MaterialTheme.typography.titleMedium, + color = NeonCyan + ) + + NavigationCard( + title = "Backend Config", + subtitle = "Configure backend server settings", + onClick = onNavigateToBackendSettings, + ) + + NavigationCard( + title = "App Settings", + subtitle = "Gateway connection settings", + onClick = onNavigateToAppSettings, + ) } } } @@ -320,3 +350,38 @@ private fun SliderSetting( ) } } + +@Composable +private fun NavigationCard( + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(GlassWhite, RoundedCornerShape(16.dp)) + .border(0.5.dp, GlassBorder, RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_chevron_right), + contentDescription = "Open", + tint = TextSecondary, + ) + } +} diff --git a/docs/android-dual-flavor-plan.md b/docs/android-dual-flavor-plan.md new file mode 100644 index 000000000..b704c4497 --- /dev/null +++ b/docs/android-dual-flavor-plan.md @@ -0,0 +1,635 @@ +# ClawDroid Dual-Flavor APK 実装計画 + +## Context + +ClawDroidは現在、Termux上でGoバイナリを手動起動し、Android APK(Kotlin/Compose)がWebSocketで接続する構成。技術的に詳しくないユーザーにも使えるよう、Goバイナリを内蔵したスタンドアロン版APKを追加する。Gradle Product Flavorsで2つのバリアントを管理する。 + +## 2つのFlavor + +| | termux | embedded | +|---|---|---| +| applicationId | `io.clawdroid`(変更なし) | `io.clawdroid`(変更なし) | +| バックエンド | ユーザーがTermuxで起動 | APK内蔵バイナリを自動起動 | +| 設定 | Config API経由でアプリ内UIから管理 | Config API経由でアプリ内UIから管理 | +| ワークスペース | SAFでユーザー選択(termuxで直接変更も可) | SAFでユーザー選択 | +| Gateway API キー | アプリ設定で手動入力(サーバー側はユーザーが管理) | 初回サービス起動時に自動生成(更新時はサービス経由で同期) | +| ポート | アプリ設定でデフォルト指定(変更可) | サービス経由で管理(デフォルト使用、アプリ設定から変更可) | + +※ applicationId は同一。同一デバイスには片方のみインストール可能。APKファイル名で区別。 + +--- + +## モジュール構成 + +機能分離は **独立したGradleモジュール** で行い、flavor source sets は DI 配線と embedded バイナリ配置に限定する。 + +``` +android/ +├── app/ # :app — エントリー、DI、ナビゲーション +│ └── src/ +│ ├── main/ # 共通コード(既存) +│ ├── termux/java/.../di/FlavorModule.kt # DI配線のみ +│ └── embedded/ +│ ├── java/.../di/FlavorModule.kt # DI配線のみ +│ └── jniLibs/{abi}/libclawdroid.so # Goバイナリ(embedded限定) +│ +├── backend/ +│ ├── api/ # :backend:api — interface + モデル + NoopBackendLifecycle(共通) +│ ├── loader/ # :backend:loader — プロセス管理(embedded用) +│ ├── loader-noop/ # :backend:loader-noop — 空実装(termux用、re-export のみ) +│ └── config/ # :backend:config — Config APIクライアント + 設定UI +│ +├── feature/chat/ # :feature:chat(既存、変更なし) +├── core/domain/ # :core:domain(既存、変更なし) +├── core/data/ # :core:data(WebSocketClient 接続パラメータ injectable 化のみ) +└── core/ui/ # :core:ui(既存、変更なし) +``` + +### 依存関係 + +``` +:app + ├── (共通) :feature:chat, :core:domain, :core:data, :core:ui, :backend:api, :backend:config + ├── (termux) :backend:loader-noop + └── (embedded) :backend:loader + +:backend:api → coroutines のみ(純Kotlin)— NoopBackendLifecycle のデフォルト実装を含む +:backend:loader → :backend:api +:backend:loader-noop → :backend:api(re-export のみ) +:backend:config → :backend:api, :core:ui(テーマ共有) + +:feature:chat → :core:domain, :core:ui(変更なし) +``` + +--- + +## Phase 1: :backend:config モジュール — Config API + 自動生成UI + +### 設計方針 + +Go側にConfig APIを追加し、Androidはconfig.goの構造を一切知らない。 +APIからスキーマとデータを取得し、Compose UIを動的に生成する。 +実装順は `implementation-steps.md` に従い、`:backend:api`(Step 2)を先行した上で進める。 + +### Go側の変更(config API追加) + +**修正**: `pkg/channels/websocket.go` or 新規 API ハンドラ + +Gateway HTTP サーバー(port 18790)に以下エンドポイント追加: + +- `GET /api/config` → 現在の設定値をJSON返却(api_keyはマスク) +- `GET /api/config/schema` → スキーマ情報返却(フィールド名、型、ラベル、デフォルト値、セクション階層) +- `PUT /api/config` → 設定を保存 → サーバー再起動 + +API キー認証(Go 側で2箇所のバリデーションが必要): +- **HTTP API**: リクエストヘッダー `Authorization: Bearer ` で検証 +- **WebSocket**: upgrade リクエストのクエリパラメータ `?api_key=...` で検証(WS upgrade 時はカスタムヘッダーが使いにくいため) +- 未設定時(api_key 空)は両方とも認証スキップ + +スキーマ例: +```json +{ + "sections": [ + { + "key": "llm", "label": "LLM", + "fields": [ + {"key": "model", "type": "string", "label": "Model", "default": ""}, + {"key": "api_key", "type": "string", "label": "API Key", "secret": true}, + {"key": "base_url", "type": "string", "label": "Base URL"} + ] + }, + ... + ] +} +``` + +WS接続時、設定未完了(LLM API key未設定等)の場合: +```json +{"type": "setup_required", "content": "LLM API key is not configured"} +``` +→ Android側はこのメッセージを受けて設定画面に自動遷移 + +### 1-1. :backend:config モジュール + +**新規**: `backend/config/build.gradle.kts` — android-library, compose, navigation, ktor-client + +### 1-2. Config API クライアント + +**新規**: `backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt` +- Ktor HTTP client で Gateway API にアクセス +- Step 3-6 では `127.0.0.1:18790`(api_key なし)を使用 +- Step 7 以降、接続先ポート + API キーは **リクエストごとに** `GatewaySettingsStore.settings.value` から取得(キャッシュしない) + - ポート変更時に次回リクエストから自動的に新ポートを使用 +- `suspend fun getSchema(): ConfigSchema` +- `suspend fun getConfig(): Map` +- `suspend fun saveConfig(config: Map): Result` → サーバー再起動トリガー + +### 1-3. 動的 Compose UI + +**新規**: `backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt` +- 設定画面を開く → ローディングスピナー → API からスキーマ+設定値を取得 → 描画 +- トップレベル: 各セクションをカード表示、タップで詳細へ + +**新規**: `backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt` +- APIスキーマの型情報に応じて描画: + - `string` → TextField(`secret: true` なら PasswordTextField) + - `bool` → Switch + - `int` → TextField with number keyboard + - `float` → TextField with decimal keyboard +- サブセクション → ネストしたナビゲーション +- ワークスペースフィールド → SAFピッカーボタン付き(内部ストレージのみ対応の注記表示) + +**新規**: `backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt` +- `ConfigApiClient` を注入 +- `StateFlow` (Loading / Loaded / Saving / Saved / Reconnecting / Error) +- 保存ボタン押下 → Saving 表示 → `saveConfig()` → 「設定を保存しました。再接続中...」表示 → WS再接続完了で自動復帰 + +### 1-4. setup_required ハンドリング(実装順は Step 8) + +Go が WS 接続時に `{"type": "setup_required"}` を送信した場合、アプリは設定画面に自動遷移する。 + +実装方針(`:core:data` を変更しない — 接続パラメータ変更は Step 7 で実施): +- `WebSocketClient` は `AppModule.kt` で `single` 登録済み → `:app` から Koin 経由で直接取得可能 +- `MainActivity` で `WebSocketClient.incomingMessages` を observe し、 + `setup_required` タイプを検知してナビゲーションイベントを発火: + ```kotlin + val wsClient: WebSocketClient by inject() + wsClient.incomingMessages.collect { message -> + if (message.type == "setup_required") { + navController.navigate(NavRoutes.BACKEND_SETTINGS) + } + } + ``` + +### 1-5. ナビゲーション統合 + +**修正**: `app/src/main/java/io/clawdroid/navigation/NavRoutes.kt` +```kotlin +object NavRoutes { + const val CHAT = "chat" + const val SETTINGS = "settings" + const val BACKEND_SETTINGS = "backend_settings" + const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" + const val APP_SETTINGS = "app_settings" +} +``` + +**修正**: `app/src/main/java/io/clawdroid/MainActivity.kt` +- backend_settings ルートを追加(両flavor共通 — Config APIはGo側なのでtermuxでもアクセス可能) +- アプリ設定(Gateway 接続設定)への導線を追加 +- SettingsScreen から Backend Settings への導線を MainActivity 側で制御 + (`:feature:chat` の SettingsScreen は変更しない) + +### 1-6. アプリ設定画面(Gateway 接続設定) + +**新規**: `app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt` +- 実装順: Step 6 で画面とナビゲーション導線を実装。Gateway 接続設定(WS/HTTP ポート、API キー)の反映処理は Step 7 で有効化 +- Gateway API キー: TextField(PasswordTextField) + - embedded: 自動生成済みの値が表示される。変更するとサービス経由でサーバーも更新 + - termux: ユーザーが手動入力。アプリ内のキーのみ更新 +- Gateway WS ポート: TextField with number keyboard(デフォルト: 18793) +- Gateway HTTP ポート: TextField with number keyboard(デフォルト: 18790) +- 保存(Step 7) → `GatewaySettingsStore.update()` → WebSocketClient が自動再接続 + +### 1-7. Config モジュール内に Koin モジュール定義 + +**新規**: `backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt` +```kotlin +val configModule = module { + single { ConfigApiClient() } // Step 7 で GatewaySettingsStore 連携版に更新 + viewModel { ConfigViewModel(get()) } +} +``` + +**修正**: `app/src/main/java/io/clawdroid/ClawDroidApp.kt` +```kotlin +modules(appModule, configModule) // Step 6: configModule 追加 +``` +※ `flavorModule` は Step 9 で追加する。 + +### 検証 +- Step 6 時点(flavor導入前)は termux 構成で設定画面導線(Backend/App Settings)と `設定画面 → セクション一覧 → 詳細 → 値編集 → 保存` を確認 +- API key入力 → 保存 → 「保存しました。再接続中...」表示 → プロセス再起動 → チャット動作 +- Step 7 で Gateway 接続設定の変更 → 即座に再接続 +- 両flavor での同等動作確認は Step 9 以降のビルドで検証 + +--- + +## Phase 2: SAFワークスペース + +### 2-1. SAFディレクトリ選択 + +ConfigSectionDetailScreen のワークスペースフィールドに SAF ピッカーを統合: +- `ACTION_OPEN_DOCUMENT_TREE` で任意ディレクトリ選択 +- `takePersistableUriPermission` で永続アクセス権取得 +- URI → ファイルパス変換(`/storage/emulated/0/...` 配下のみ対応) +- SDカード・USB OTG: 現時点では非対応。UI 上で「内部ストレージのみ対応」と明示 +- 非対応パス選択時: エラー表示 + デフォルト(`getExternalFilesDir("workspace")`)にフォールバック +- デフォルト: `getExternalFilesDir("workspace")` + +### 検証 +- ワークスペースを Downloads に設定 → ファイルマネージャーで確認 +- Goバイナリがそのディレクトリに読み書きできること +- 非対応パス(SDカード等)選択時にエラー表示されること + +### 注: feature/chat, core/domain, core/ui モジュールは変更なし + +WebSocketクライアントの既存の再接続ロジック(CONNECTING/RECONNECTING表示)が +バックエンド起動待ちを暗黙的にカバーするため、ChatUiState/ConnectionBannerの修正は不要。 +Backend Settings へのナビゲーションは `MainActivity.kt` で両flavor共通の +トップレベルルートとして追加する。 + +--- + +## Phase 3: Gradle Flavor + :backend:loader-noop(接続設定は Step 7 で実装) + +### 3-1. `settings.gradle.kts` にモジュール追加(Step 9 対応分) + +```kotlin +include(":backend:loader-noop") +``` + +※ `:backend:api` は Step 2、`:backend:config` は Step 3 で追加済み。 +※ `:backend:loader` は Step 11 で追加する。 + +### 3-2. `app/build.gradle.kts` に flavors + flavor依存 + +```kotlin +android { + flavorDimensions += "variant" + productFlavors { + create("termux") { dimension = "variant" } + create("embedded") { dimension = "variant" } + } + buildFeatures { + compose = true + buildConfig = true // BuildConfig.FLAVOR で判定 + } + sourceSets { + getByName("termux") { java.srcDirs("src/termux/java") } + getByName("embedded") { + java.srcDirs("src/embedded/java") + } + } +} + +dependencies { + // 共通 + implementation(project(":backend:api")) + implementation(project(":backend:config")) + // ... 既存依存 ... + + // flavor固有 + "termuxImplementation"(project(":backend:loader-noop")) +} +``` + +※ `embeddedImplementation(project(":backend:loader"))` は Step 11 で追加する。 + +### 3-3. :backend:api モジュール(実装順は Step 2 で先行) + +**新規**: `backend/api/build.gradle.kts` — android-library, coroutines依存のみ + +**新規**: `backend/api/src/main/java/io/clawdroid/backend/api/BackendState.kt` +```kotlin +enum class BackendState { STOPPED, STARTING, RUNNING, ERROR } +``` + +**新規**: `backend/api/src/main/java/io/clawdroid/backend/api/BackendLifecycle.kt` +```kotlin +interface BackendLifecycle { + val state: StateFlow + val isManaged: Boolean // false for termux, true for embedded + suspend fun start() + suspend fun stop() +} +``` + +**新規**: `backend/api/src/main/java/io/clawdroid/backend/api/NoopBackendLifecycle.kt` +```kotlin +/** デフォルト実装 — バックエンドは常に RUNNING(termux用、embedded 初期スタブ兼用) */ +class NoopBackendLifecycle : BackendLifecycle { + override val state = MutableStateFlow(BackendState.RUNNING) + override val isManaged = false + override suspend fun start() {} + override suspend fun stop() {} +} +``` + +### 3-4. :backend:loader-noop モジュール(termux用) + +**新規**: `backend/loader-noop/build.gradle.kts` — android-library, `:backend:api` に依存 +- `NoopBackendLifecycle` は `:backend:api` にあるため、このモジュールは依存の分離のみ担当 +- 将来 termux 固有ロジック(Termux:API intent 連携等)が必要になった場合の拡張ポイント + +### 3-5. Gateway 接続設定(アプリローカル設定、実装順は Step 7) + +Gateway API キーとポートは **Config API とは別のアプリローカル設定**(SharedPreferences / DataStore)で管理する。 +WebSocketClient と ConfigApiClient の接続先を動的に設定可能にする。 +Step 7 で `ConfigApiClient` を `GatewaySettingsStore` 参照版に更新する。 + +**新規**: `backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt` +```kotlin +/** Gateway サーバーへの接続設定 */ +data class GatewaySettings( + val wsPort: Int = 18793, + val httpPort: Int = 18790, + val apiKey: String = "", // 空 = 認証なし +) +``` + +**新規**: `backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettingsStore.kt` +```kotlin +/** アプリローカルの Gateway 接続設定ストア */ +interface GatewaySettingsStore { + val settings: StateFlow + suspend fun update(settings: GatewaySettings) +} +``` + +**新規**: `app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt` +```kotlin +/** DataStore ベースの実装(プロジェクトの datastore-preferences に準拠) */ +class GatewaySettingsStoreImpl(context: Context) : GatewaySettingsStore { + // DataStore で wsPort, httpPort, apiKey を永続化 + // StateFlow で変更を公開 +} +``` + +**修正**: `core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt` +- 現在 `var wsUrl: String = "ws://127.0.0.1:18793/ws"` がパブリック var として存在 +- コンストラクタ変更なし(`HttpClient, CoroutineScope, clientId, clientType`) +- `connect()` 内で URL 構築時に API キーをクエリパラメータに追加: + ```kotlin + val url = "$wsUrl?client_id=$clientId&client_type=$clientType&api_key=$apiKey" + ``` +- `apiKey` プロパティを追加(`var apiKey: String = ""`) +- wsUrl / apiKey の変更は外部(AppModule の settings observe)で行い、 + 変更時に `disconnect()` → `connect()` で再接続 + +### 3-6. Flavor source sets(DI配線のみ) + +**新規**: `app/src/termux/java/io/clawdroid/di/FlavorModule.kt` +```kotlin +val flavorModule = module { + single { NoopBackendLifecycle() } +} +``` + +**新規**: `app/src/embedded/java/io/clawdroid/di/FlavorModule.kt` +```kotlin +val flavorModule = module { + // Phase 4 で EmbeddedBackendLifecycle に差し替え。Phase 3 では NoopBackendLifecycle をスタブ使用 + single { NoopBackendLifecycle() } +} +``` + +### 3-7. 既存ファイル修正 + +**修正**: `app/src/main/java/io/clawdroid/ClawDroidApp.kt` +```kotlin +modules(appModule, flavorModule, configModule) // flavorModule 追加(configModule は Step 6 で追加済み) +``` + +Koin 初期化後、GatewaySettingsStore の変更を observe して WebSocketClient を再接続: +```kotlin +val settingsStore: GatewaySettingsStore = get() +val wsClient: WebSocketClient = get() +scope.launch { + settingsStore.settings + .drop(1) // 初回値スキップ(AppModule で初期化済み) + .collect { s -> + wsClient.wsUrl = "ws://127.0.0.1:${s.wsPort}/ws" + wsClient.apiKey = s.apiKey + wsClient.disconnect() + wsClient.connect() + } +} +``` + +**修正**: `app/src/main/java/io/clawdroid/di/AppModule.kt` +```kotlin +// GatewaySettingsStore +single { GatewaySettingsStoreImpl(androidContext()) } + +// WebSocketClient — コンストラクタは変更なし、wsUrl と apiKey を settings から設定 +single { + val prefs = androidContext().getSharedPreferences("clawdroid", android.content.Context.MODE_PRIVATE) + val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString("client_id", it).apply() + } + val settingsStore = get() + val settings = settingsStore.settings.value + WebSocketClient(get(), get(), clientId).apply { + wsUrl = "ws://127.0.0.1:${settings.wsPort}/ws" + apiKey = settings.apiKey + } +} + +``` + +### 検証 +- `./gradlew assembleTermuxDebug` と `./gradlew assembleEmbeddedDebug` 両方ビルド成功 +- termux版は現状と同じ動作(デフォルトポートで接続) + +--- + +## Phase 4: :backend:loader モジュール — バイナリ同梱 + プロセス管理 + +### バイナリ配置方式: jniLibs アプローチ(embedded source set) + +Go バイナリを `libclawdroid.so` としてネイティブライブラリに偽装し、**embedded flavor の `jniLibs`** に配置する。 +termux 版 APK にはバイナリが含まれない。 + +これにより: +- Android 標準の ABI フィルタリングが自動で効く(ABI split 不要) +- `context.applicationInfo.nativeLibraryDir` から直接実行可能 +- `filesDir` へのコピーや実行権限付与が不要 +- SELinux の W^X ポリシー制限を回避(nativeLibraryDir は exec 許可済み) +- termux 版 APK サイズに影響なし + +### 4-1. Makefile に `build-android` ターゲット追加 + +**修正**: `Makefile` +```makefile +build-android: generate + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath $(LDFLAGS) \ + -o android/app/src/embedded/jniLibs/arm64-v8a/libclawdroid.so ./cmd/clawdroid + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath $(LDFLAGS) \ + -o android/app/src/embedded/jniLibs/x86_64/libclawdroid.so ./cmd/clawdroid + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath $(LDFLAGS) \ + -o android/app/src/embedded/jniLibs/armeabi-v7a/libclawdroid.so ./cmd/clawdroid +``` + +注: x86(32bit)は非対応。エミュレータ開発時は x86_64 を使用すること。 + +### 4-2. :backend:loader モジュール + +**新規**: `backend/loader/build.gradle.kts` — android-library, lifecycle-service, coroutines + +**修正**: `settings.gradle.kts`(Step 11) +```kotlin +include(":backend:loader") +``` + +**修正**: `app/build.gradle.kts`(Step 11) +```kotlin +"embeddedImplementation"(project(":backend:loader")) +``` + +**新規**: `backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayProcessManager.kt` +- `ProcessBuilder` で `nativeLibraryDir/libclawdroid.so gateway run` を起動 + - バイナリパス: `context.applicationInfo.nativeLibraryDir + "/libclawdroid.so"` +- 環境変数: + - `HOME` → `context.filesDir`(Go側の `~/.clawdroid/config.json` がアプリ内に解決される) + - `CLAWDROID_GATEWAY_API_KEY` → GatewaySettingsStore から取得した API キー + - 他の設定はGoが `config.json` から読み取る。変更は Config API (`PUT /api/config`) 経由 +- stdout/stderr → `Log.i` に転送(別スレッドで読み取り) +- プロセス死亡時 exponential backoff で再起動(1s→2s→4s→...→30s上限) +- `StateFlow` で状態公開 +- WebSocket接続可能までポーリング → `RUNNING` 遷移 +- **PID ファイル管理**: + - 起動時: PID を `filesDir/clawdroid.pid` に記録 + - 起動前: PID ファイルが存在すれば `/proc//cmdline` でプロセス生存確認 → 生存なら kill + - 停止時・onDestroy: PID ファイル削除 +- **API キー初回生成** (embedded): + - GatewaySettingsStore の apiKey が空なら `UUID.randomUUID()` で生成 + - 生成したキーを GatewaySettingsStore に保存 + Go プロセスに環境変数で渡す +- **API キー更新**: + - GatewaySettingsStore の変更を observe → Go プロセスを新しいキーで再起動 + +**新規**: `backend/loader/src/main/java/io/clawdroid/backend/loader/EmbeddedBackendLifecycle.kt` +- `BackendLifecycle` 実装 +- `start()` = GatewayService が起動済みか判定 → 未起動なら ForegroundService を起動 +- `isManaged = true` +- `state` は GatewayProcessManager の StateFlow を委譲 + +**新規**: `backend/loader/src/main/java/io/clawdroid/backend/loader/GatewayService.kt` +- `LifecycleService` 継承の ForegroundService +- **Go プロセスのオーナー** — Service が起動/停止の責任を持つ +- `onCreate`: + - `GatewayProcessManager.start()` — 孤児チェック → API キー初回生成 → Go プロセス起動 + - `startForeground()` で通知表示("ClawDroid バックエンド実行中") +- `onStartCommand`: + ```kotlin + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // intent が null でも問題なし — 初期化は onCreate で完了済み + // START_STICKY による OS kill 後の再起動時は intent=null で呼ばれる + return START_STICKY + } + ``` +- `onDestroy` → `GatewayProcessManager.stop()` +- アプリがバックグラウンドに行ってもServiceが生き続けるのでGoプロセスも継続 + +**新規**: `backend/loader/src/main/AndroidManifest.xml` +```xml + + + + + + + + + + +``` + +注: minSdk 30 のため `FOREGROUND_SERVICE` は必ずサポート済み。 +`FOREGROUND_SERVICE_SPECIAL_USE` は API 34+ で必須だが、API 30-33 では属性が無視されるだけで問題なし。 + +### 4-3. FlavorModule 更新(embedded) + +**修正**: `app/src/embedded/java/io/clawdroid/di/FlavorModule.kt` +```kotlin +val flavorModule = module { + single { GatewayProcessManager(androidContext(), get()) } // GatewaySettingsStore を注入 + single { EmbeddedBackendLifecycle(androidContext(), get()) } +} +``` + +### 4-4. アプリ起動フロー + +**修正**: `app/src/main/java/io/clawdroid/ClawDroidApp.kt` +- Koin初期化後に `BackendLifecycle.start()` を呼ぶ + - **termux版**: no-op(Termux側でGoが起動済み前提) + - **embedded版**: GatewayServiceが起動済みか判定 + - 起動済み → 何もしない(WS接続はWebSocketClientの自動再接続に任せる) + - 未起動 → `startForegroundService(Intent(GatewayService))` で起動 + - Service内: API キー初回生成(未設定時) → Go プロセス起動 → `libclawdroid.so gateway run` + +### 検証 +- embedded版でアプリ起動 → API キー自動生成 → Goプロセスが起動 → WebSocket接続成功 → チャットが動作 +- `adb shell ps | grep clawdroid` でプロセス確認 +- termux版は影響なし + +--- + +## 新規ファイル一覧 + +| ファイル | Step | モジュール | +|---------|------|-----------| +| `backend/config/build.gradle.kts` | 3 | :backend:config | +| `backend/config/.../ConfigApiClient.kt` | 3 | :backend:config | +| `backend/config/.../ConfigSectionListScreen.kt` | 4 | :backend:config | +| `backend/config/.../ConfigSectionDetailScreen.kt` | 4 | :backend:config | +| `backend/config/.../ConfigViewModel.kt` | 4 | :backend:config | +| `backend/config/.../ConfigModule.kt` | 4 | :backend:config | +| `app/src/main/.../settings/AppSettingsScreen.kt` | 6 | :app | +| Go: config API handler(新規 or 既存ファイル修正) | 1 | Go側 | +| `backend/api/build.gradle.kts` | 2 | :backend:api | +| `backend/api/.../BackendState.kt` | 2 | :backend:api | +| `backend/api/.../BackendLifecycle.kt` | 2 | :backend:api | +| `backend/api/.../NoopBackendLifecycle.kt` | 2 | :backend:api | +| `backend/api/.../GatewaySettings.kt` | 2 | :backend:api | +| `backend/api/.../GatewaySettingsStore.kt` | 2 | :backend:api | +| `backend/loader-noop/build.gradle.kts` | 9 | :backend:loader-noop | +| `backend/loader-noop/src/main/AndroidManifest.xml` | 9 | :backend:loader-noop | +| `app/src/main/.../settings/GatewaySettingsStoreImpl.kt` | 7 | :app | +| `app/src/termux/java/.../di/FlavorModule.kt` | 9 | :app (flavor) | +| `app/src/embedded/java/.../di/FlavorModule.kt` | 9-13 | :app (flavor) | +| `app/src/embedded/jniLibs/{abi}/libclawdroid.so` | 10 | :app (jniLibs, embedded限定) | +| `backend/loader/build.gradle.kts` | 11 | :backend:loader | +| `backend/loader/.../GatewayProcessManager.kt` | 11 | :backend:loader | +| `backend/loader/.../EmbeddedBackendLifecycle.kt` | 12 | :backend:loader | +| `backend/loader/.../GatewayService.kt` | 12 | :backend:loader | +| `backend/loader/src/main/AndroidManifest.xml` | 11 | :backend:loader | + +## 修正ファイル一覧 + +| ファイル | Step | 変更内容 | +|---------|------|---------| +| `android/app/.../NavRoutes.kt` | 6 | `BACKEND_SETTINGS`, `BACKEND_SETTINGS_SECTION`, `APP_SETTINGS` 追加 | +| `android/app/.../MainActivity.kt` | 6, 8 | 設定画面ナビゲーション追加、`setup_required` observe 追加 | +| `android/app/.../ClawDroidApp.kt` | 6, 7, 9, 13 | `configModule`/`flavorModule` 追加、settings observe、`BackendLifecycle.start()` | +| `android/settings.gradle.kts` | 2, 3, 9, 11 | `:backend:api`/`:backend:config`/`:backend:loader-noop`/`:backend:loader` を段階的に include | +| `android/app/build.gradle.kts` | 3, 9, 11 | `:backend:config` 依存、flavors/sourceSets/loader-noop 依存、`embeddedImplementation(:backend:loader)` | +| `android/app/.../di/AppModule.kt` | 7 | `GatewaySettingsStore` 登録、WebSocketClient 注入変更 | +| `core/data/.../WebSocketClient.kt` | 7 | `var apiKey: String` 追加、`connect()` で API キーをクエリパラメータに付加(コンストラクタ変更なし) | +| `backend/config/.../ConfigSectionDetailScreen.kt` | 5 | SAF ワークスペース選択統合 | +| `pkg/channels/websocket.go` | 1, 8 | Config API 関連処理、WS `api_key` 認証と `setup_required` 送信 | +| `Makefile` | 10 | `build-android` ターゲット追加 | + +**変更なし**: `:feature:chat`, `:core:domain`, `:core:ui` +**最小限の変更**: `:core:data` — WebSocketClient の接続パラメータ injectable 化のみ + +--- + +## リスクと対策 + +1. **jniLibs exec 互換性** — `nativeLibraryDir` からの exec は全 Android バージョンで許可(標準的な NDK ライブラリ配置)。Go バイナリは ELF 形式で `.so` 拡張子でも正常動作。万が一問題があれば `filesDir` コピー+exec にフォールバック +2. **APKサイズ** — Goバイナリ ~30MB/ABI。embedded source set の jniLibs に配置するため、termux 版 APK には含まれない。Android 標準の ABI フィルタリングにより、デバイスには該当 ABI のみインストール。Play Store 配信時は App Bundle で自動分割 +3. **SAFパス変換** — content URI → ファイルパスは `/storage/emulated/0/` 配下のみ対応。SDカード・USB OTG は非対応とし、UI 上で「内部ストレージのみ対応」と明示。非対応パス選択時は `getExternalFilesDir` にフォールバック +4. **プロセス孤児化** — `GatewayProcessManager` が PID を `filesDir/clawdroid.pid` に記録。起動時に `/proc//cmdline` で孤児チェック → 生存なら kill → PID ファイル更新。`onDestroy` で PID ファイル削除 +5. **config.go との同期** — API化により不要。Go側にフィールド追加すればスキーマAPIが自動反映 +6. **同一applicationId** — 両flavorが同ID、同一端末に共存不可。APKファイル名で区別 +7. **Config 保存後の再接続** — `PUT /api/config` 後にGoプロセス再起動でWS切断。ConfigViewModel が UI 状態を Saving → Saved → Reconnecting と遷移させ、既存の WebSocket 再接続ロジック(exponential backoff)で自動復旧 +8. **ForegroundService パーミッション** — `FOREGROUND_SERVICE`(minSdk 30 で必ずサポート)と `FOREGROUND_SERVICE_SPECIAL_USE`(API 34+)を AndroidManifest に宣言。API 30-33 では specialUse 属性が無視されるだけで問題なし +9. **x86 ABI** — 非対応。エミュレータ開発時は x86_64 を使用。必要になれば `GOARCH=386` ビルド追加で対応可 +10. **Gateway API キー セキュリティ** — localhost 通信のためネットワーク経由の盗聴リスクは低い。主な目的は同一端末上の他アプリからの不正アクセス防止 +11. **ProGuard / R8** — `:backend:config` は Ktor HTTP クライアントで JSON を動的処理するため、release ビルド時に R8 でシリアライゼーション関連クラスが strip されないよう ProGuard ルール追加が必要。既存の `proguard-rules.pro` を確認し、Ktor + kotlinx.serialization のルールを追記 diff --git a/docs/implementation-steps.md b/docs/implementation-steps.md new file mode 100644 index 000000000..4fcd3a128 --- /dev/null +++ b/docs/implementation-steps.md @@ -0,0 +1,205 @@ +# Dual-Flavor 実装ステップ + +設計書: `docs/android-dual-flavor-plan.md` に基づく段階的実装計画。 +各ステップは独立してビルド検証可能な単位に分割している。 + +--- + +## Config をアプリから設定できるようにする + +### Step 1: Go — Gateway HTTP サーバー + Config API エンドポイント + +Gateway HTTP サーバーを新規作成し、Config API を実装する。 + +**新規 or 修正ファイル(Go 側):** +- Gateway HTTP サーバー作成(port 18790) + - `GET /api/config` — 設定値返却(api_key はマスク) + - `GET /api/config/schema` — スキーマ返却(フィールド名、型、ラベル、デフォルト値、セクション階層) + - `PUT /api/config` — 設定保存 → サーバー再起動トリガー + - HTTP API 認証: + - `Authorization: Bearer ` で検証 + - `api_key` 未設定時は認証スキップ + +**検証:** +- `go test ./...` パス +- `curl http://127.0.0.1:18790/api/config/schema` でスキーマ取得 +- `curl http://127.0.0.1:18790/api/config` で設定取得 +- `curl -X PUT http://127.0.0.1:18790/api/config -d '{...}'` で設定保存 +- `api_key` 設定時: Bearer なし/不一致で HTTP API が拒否される +- `api_key` 設定時: `Authorization: Bearer ` で HTTP API が通る + +--- + +### Step 2: :backend:api モジュール — 骨格 + インターフェース/モデル + +**作成ファイル:** +- `android/backend/api/build.gradle.kts` — android-library, coroutines 依存のみ +- `android/backend/api/src/main/AndroidManifest.xml` +- `BackendState.kt` +- `BackendLifecycle.kt` +- `NoopBackendLifecycle.kt` +- `GatewaySettings.kt` +- `GatewaySettingsStore.kt` + +**修正ファイル:** +- `android/settings.gradle.kts` — `include(":backend:api")` 追加 + +**検証:** `./gradlew :backend:api:compileDebugKotlin` + +--- + +### Step 3: :backend:config モジュール + ConfigApiClient + +**作成ファイル:** +- `android/backend/config/build.gradle.kts` — android-library, compose, navigation, ktor-client, `:backend:api`, `:core:ui`(テーマ共有) +- `android/backend/config/src/main/AndroidManifest.xml` +- `ConfigApiClient.kt` + +**修正ファイル:** +- `android/settings.gradle.kts` — `include(":backend:config")` 追加 +- `android/app/build.gradle.kts` — `implementation(project(":backend:api"))`, `implementation(project(":backend:config"))` 追加 + +**検証:** `./gradlew :backend:config:compileDebugKotlin` + +--- + +### Step 4: Config UI(ViewModel + Screen) + +**作成ファイル:** +- `ConfigViewModel.kt` +- `ConfigSectionListScreen.kt` +- `ConfigSectionDetailScreen.kt` +- `ConfigModule.kt` + +**検証:** `./gradlew :backend:config:compileDebugKotlin` + +--- + +### Step 5: SAF ワークスペース(Config UI に統合) + +**修正ファイル:** +- `ConfigSectionDetailScreen.kt`: + - ワークスペースフィールドに SAF ピッカーボタン追加 + - `ACTION_OPEN_DOCUMENT_TREE` + `takePersistableUriPermission` + - URI → ファイルパス変換(内部ストレージのみ対応) + - 非対応パス選択時のエラー表示 + フォールバック + +**検証:** +- ワークスペースを任意ディレクトリに変更可能 +- 非対応パス(SDカード等)選択時にエラー表示 + +--- + +### Step 6: AppSettingsScreen + ナビゲーション統合 + +**作成ファイル:** +- `app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt` + +**修正ファイル:** +- `NavRoutes.kt` — `BACKEND_SETTINGS`, `BACKEND_SETTINGS_SECTION`, `APP_SETTINGS` 追加 +- `MainActivity.kt` — ルート追加 +- `ClawDroidApp.kt` — `configModule` 追加 + +**検証:** Termux 環境で設定画面 → セクション一覧 → 詳細 → 値編集 → 保存 + +--- + +## 接続設定 + API キー + +### Step 7: GatewaySettingsStore + WebSocketClient apiKey + ConfigApiClient 連携 + +**作成ファイル:** +- `app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt` + +**修正ファイル:** +- `AppModule.kt` — `GatewaySettingsStore` 登録 + WebSocketClient に settings 反映 +- `WebSocketClient.kt` — `var apiKey` 追加、`connect()` で `&api_key=` 付加 +- `ClawDroidApp.kt` — settings observe → WS 再接続 +- `ConfigApiClient.kt` — コンストラクタに `GatewaySettingsStore` 追加、リクエストごとに `settings.value` からポート + API キーを取得 +- `ConfigModule.kt` — `single { ConfigApiClient(get()) }` に更新(GatewaySettingsStore 注入) +- `AppSettingsScreen.kt` — 保存ボタンの `GatewaySettingsStore.update()` 呼び出しを有効化 + +**検証:** ビルド成功、接続設定変更で WS 再接続、AppSettings で保存 → ConfigApiClient が新ポート/キーを使用 + +--- + +### Step 8: Go — WS API キー認証 + setup_required メッセージ + +**修正ファイル:** +- `pkg/channels/websocket.go`: + - WS upgrade 時に `?api_key=` クエリパラメータで認証 + - 未設定時は認証スキップ + - LLM API key 未設定等で `{"type": "setup_required", "content": "..."}` 送信 +- `MainActivity.kt` — `setup_required` observe → 設定画面遷移 + +**検証:** +- API key 不一致で WS 接続拒否 +- LLM 未設定で setup_required → 設定画面に自動遷移 + +--- + +## Flavor 分離 + +### Step 9: Product Flavors + :backend:loader-noop + FlavorModule + +**作成ファイル:** +- `android/backend/loader-noop/build.gradle.kts` +- `android/backend/loader-noop/src/main/AndroidManifest.xml` +- `app/src/termux/java/io/clawdroid/di/FlavorModule.kt` +- `app/src/embedded/java/io/clawdroid/di/FlavorModule.kt`(初期は Noop) + +**修正ファイル:** +- `android/settings.gradle.kts` — `include(":backend:loader-noop")` 追加 +- `app/build.gradle.kts` — flavors, buildConfig, sourceSets, flavor 依存追加 +- `ClawDroidApp.kt` — `modules(appModule, flavorModule, configModule)` + +**検証:** `./gradlew assembleTermuxDebug` と `./gradlew assembleEmbeddedDebug` 両方成功 + +--- + +## Embedded 固有 + +### Step 10: Makefile build-android + +**修正ファイル:** +- `Makefile` — `build-android` ターゲット追加(arm64, x86_64, armv7) + +**検証:** `make build-android` → `android/app/src/embedded/jniLibs/` に .so 生成 + +--- + +### Step 11: :backend:loader + GatewayProcessManager + +**作成ファイル:** +- `android/backend/loader/build.gradle.kts` +- `android/backend/loader/src/main/AndroidManifest.xml` — ForegroundService 宣言 +- `GatewayProcessManager.kt` + +**修正ファイル:** +- `android/settings.gradle.kts` — `include(":backend:loader")` 追加 +- `android/app/build.gradle.kts` — `embeddedImplementation(project(":backend:loader"))` 追加 + +**検証:** `./gradlew :backend:loader:compileDebugKotlin` + +--- + +### Step 12: EmbeddedBackendLifecycle + GatewayService + +**作成ファイル:** +- `EmbeddedBackendLifecycle.kt` +- `GatewayService.kt` — LifecycleService, ForegroundService, START_STICKY + +**検証:** `./gradlew :backend:loader:compileDebugKotlin` + +--- + +### Step 13: embedded FlavorModule + 起動フロー統合 + +**修正ファイル:** +- `app/src/embedded/java/io/clawdroid/di/FlavorModule.kt` — EmbeddedBackendLifecycle に差し替え +- `ClawDroidApp.kt` — `BackendLifecycle.start()` 呼び出し追加 + +**検証:** +- `./gradlew assembleEmbeddedDebug` ビルド成功 +- embedded 版で Go プロセス起動 → WS 接続 → チャット動作 +- `adb shell ps | grep clawdroid` でプロセス確認