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:
parent
3edf6c6a8c
commit
908c1e051b
7 changed files with 1213 additions and 2 deletions
|
|
@ -9,13 +9,18 @@ import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.core.ui.theme.ClawDroidTheme
|
||||||
import io.clawdroid.feature.chat.screen.ChatScreen
|
import io.clawdroid.feature.chat.screen.ChatScreen
|
||||||
import io.clawdroid.feature.chat.screen.SettingsScreen
|
import io.clawdroid.feature.chat.screen.SettingsScreen
|
||||||
import io.clawdroid.navigation.NavRoutes
|
import io.clawdroid.navigation.NavRoutes
|
||||||
|
import io.clawdroid.settings.AppSettingsScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -38,7 +43,31 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
composable(NavRoutes.SETTINGS) {
|
composable(NavRoutes.SETTINGS) {
|
||||||
SettingsScreen(
|
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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,7 @@ package io.clawdroid.navigation
|
||||||
object NavRoutes {
|
object NavRoutes {
|
||||||
const val CHAT = "chat"
|
const val CHAT = "chat"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
const val BACKEND_SETTINGS = "backend_settings"
|
||||||
|
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
|
||||||
|
const val APP_SETTINGS = "app_settings"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
package io.clawdroid.feature.chat.screen
|
package io.clawdroid.feature.chat.screen
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
|
@ -59,6 +66,8 @@ import org.koin.androidx.compose.koinViewModel
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToBackendSettings: () -> Unit,
|
||||||
|
onNavigateToAppSettings: () -> Unit,
|
||||||
viewModel: SettingsViewModel = koinViewModel()
|
viewModel: SettingsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
@ -114,7 +123,8 @@ fun SettingsScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(16.dp),
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -159,6 +169,26 @@ fun SettingsScreen(
|
||||||
) {
|
) {
|
||||||
Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
635
docs/android-dual-flavor-plan.md
Normal file
635
docs/android-dual-flavor-plan.md
Normal file
|
|
@ -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 <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 キー: 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<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
|
||||||
|
/** デフォルト実装 — バックエンドは常に 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<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 sets(DI配線のみ)
|
||||||
|
|
||||||
|
**新規**: `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
|
||||||
|
```
|
||||||
|
|
||||||
|
注: 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<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-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/<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 のルールを追記
|
||||||
205
docs/implementation-steps.md
Normal file
205
docs/implementation-steps.md
Normal 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 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` でプロセス確認
|
||||||
Loading…
Add table
Reference in a new issue