feat: add AppSettingsScreen and navigation integration (Step 6)

Add gateway connection settings UI (AppSettingsScreen + ViewModel),
navigation routes for backend config and app settings, and navigation
cards in SettingsScreen for accessing the new screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-27 15:21:55 +09:00
parent 3edf6c6a8c
commit 908c1e051b
7 changed files with 1213 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,635 @@
# ClawDroid Dual-Flavor APK 実装計画
## Context
ClawDroidは現在、Termux上でGoバイナリを手動起動し、Android APKKotlin/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:dataWebSocketClient 接続パラメータ 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:apire-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 <api_key>` で検証
- **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<String, Any?>`
- `suspend fun saveConfig(config: Map<String, Any?>): 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<ConfigUiState>` (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 キー: TextFieldPasswordTextField
- 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<BackendState>
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
/** デフォルト実装 — バックエンドは常に RUNNINGtermux用、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<GatewaySettings>
suspend fun update(settings: GatewaySettings)
}
```
**新規**: `app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt`
```kotlin
/** DataStore ベースの実装(プロジェクトの datastore-preferences に準拠) */
class GatewaySettingsStoreImpl(context: Context) : GatewaySettingsStore {
// DataStore<Preferences> で 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 setsDI配線のみ
**新規**: `app/src/termux/java/io/clawdroid/di/FlavorModule.kt`
```kotlin
val flavorModule = module {
single<BackendLifecycle> { NoopBackendLifecycle() }
}
```
**新規**: `app/src/embedded/java/io/clawdroid/di/FlavorModule.kt`
```kotlin
val flavorModule = module {
// Phase 4 で EmbeddedBackendLifecycle に差し替え。Phase 3 では NoopBackendLifecycle をスタブ使用
single<BackendLifecycle> { 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<GatewaySettingsStore> { 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<GatewaySettingsStore>()
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
```
注: x8632bitは非対応。エミュレータ開発時は 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<BackendState>` で状態公開
- WebSocket接続可能までポーリング → `RUNNING` 遷移
- **PID ファイル管理**:
- 起動時: PID を `filesDir/clawdroid.pid` に記録
- 起動前: PID ファイルが存在すれば `/proc/<pid>/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
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application>
<service
android:name=".loader.GatewayService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Local AI agent backend process" />
</service>
</application>
</manifest>
```
注: 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<BackendLifecycle> { EmbeddedBackendLifecycle(androidContext(), get()) }
}
```
### 4-4. アプリ起動フロー
**修正**: `app/src/main/java/io/clawdroid/ClawDroidApp.kt`
- Koin初期化後に `BackendLifecycle.start()` を呼ぶ
- **termux版**: no-opTermux側で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/<pid>/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 のルールを追記

View file

@ -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>` で検証
- `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 <api_key>` で 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 UIViewModel + 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` でプロセス確認