diff --git a/android/app/src/main/java/io/clawdroid/MainActivity.kt b/android/app/src/main/java/io/clawdroid/MainActivity.kt index 806822d50..f5d5873b1 100644 --- a/android/app/src/main/java/io/clawdroid/MainActivity.kt +++ b/android/app/src/main/java/io/clawdroid/MainActivity.kt @@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() { SettingsScreen( onNavigateBack = { navController.popBackStack() }, onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) }, - onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) }, + onNavigateToAppSettings = { navController.navigate(NavRoutes.appSettings()) }, ) } navigation( @@ -86,6 +86,9 @@ class MainActivity : ComponentActivity() { onSectionSelected = { sectionKey -> navController.navigate("backend_settings/$sectionKey") }, + onNavigateToAppSettings = { + navController.navigate(NavRoutes.appSettings(localOnly = true)) + }, viewModel = viewModel, ) } @@ -104,7 +107,15 @@ class MainActivity : ComponentActivity() { ) } } - composable(NavRoutes.APP_SETTINGS) { + composable( + NavRoutes.APP_SETTINGS, + arguments = listOf( + navArgument("localOnly") { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { AppSettingsScreen( onNavigateBack = { navController.popBackStack() }, ) diff --git a/android/app/src/main/java/io/clawdroid/di/AppModule.kt b/android/app/src/main/java/io/clawdroid/di/AppModule.kt index d2ee5c88d..2ba889d0b 100644 --- a/android/app/src/main/java/io/clawdroid/di/AppModule.kt +++ b/android/app/src/main/java/io/clawdroid/di/AppModule.kt @@ -135,6 +135,6 @@ val appModule = module { // ViewModel viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } - viewModel { AppSettingsViewModel(get(), get()) } + viewModel { AppSettingsViewModel(get(), get(), get()) } viewModel { SetupViewModel(get(), get()) } } diff --git a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt index fcc94f48b..efd6dea17 100644 --- a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt +++ b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt @@ -6,6 +6,9 @@ object NavRoutes { const val BACKEND_SETTINGS = "backend_settings" const val BACKEND_SETTINGS_LIST = "backend_settings_list" const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" - const val APP_SETTINGS = "app_settings" + const val APP_SETTINGS = "app_settings?localOnly={localOnly}" const val SETUP = "setup" + + fun appSettings(localOnly: Boolean = false): String = + "app_settings?localOnly=$localOnly" } diff --git a/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt index 1e3dbabb1..819007520 100644 --- a/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt +++ b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt @@ -1,5 +1,6 @@ package io.clawdroid.settings +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.clawdroid.backend.api.GatewaySettings @@ -30,10 +31,13 @@ private fun portError(value: String): String? { } class AppSettingsViewModel( + savedStateHandle: SavedStateHandle, private val settingsStore: GatewaySettingsStore, private val configApiClient: ConfigApiClient, ) : ViewModel() { + private val localOnly: Boolean = savedStateHandle["localOnly"] ?: false + private val _uiState = MutableStateFlow(AppSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -75,8 +79,9 @@ class AppSettingsViewModel( } try { - configApiClient.saveConfig(payload) - // Persist new values locally after remote success + if (!localOnly) { + configApiClient.saveConfig(payload) + } settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey)) _uiState.update { it.copy(saving = false) } onComplete() diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt index ac59b5acf..1b530d30e 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt @@ -44,6 +44,8 @@ data class SaveConfigResult( val error: String? = null, ) +class AuthException(message: String) : IOException(message) + class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable { private val baseUrl: String get() = settingsStore.settings.value.httpBaseUrl @@ -84,7 +86,9 @@ class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeab private suspend fun HttpResponse.ensureSuccess(): HttpResponse { if (!status.isSuccess()) { val error = runCatching { body().error }.getOrNull() - throw IOException("HTTP ${status.value}: ${error ?: "request failed"}") + val message = "HTTP ${status.value}: ${error ?: "request failed"}" + if (status.value == 403) throw AuthException(message) + throw IOException(message) } return this } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt index af2ec39b1..c53802c4e 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt @@ -48,6 +48,7 @@ import io.clawdroid.core.ui.theme.TextSecondary fun ConfigSectionListScreen( onNavigateBack: () -> Unit, onSectionSelected: (sectionKey: String) -> Unit, + onNavigateToAppSettings: () -> Unit, viewModel: ConfigViewModel, ) { val uiState by viewModel.uiState.collectAsState() @@ -112,6 +113,43 @@ fun ConfigSectionListScreen( } } + is ListState.AuthRequired -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + listState.message, + color = TextSecondary, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = onNavigateToAppSettings, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + ) { + Text("Connection Settings") + } + Spacer(Modifier.height(8.dp)) + Button( + onClick = viewModel::retry, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = NeonCyan, + ), + ) { + Text("Retry") + } + } + } + } + is ListState.Loaded -> { LazyColumn( modifier = Modifier diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt index 9d1110378..8cccbc1fe 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt @@ -9,6 +9,7 @@ data class ConfigUiState( sealed interface ListState { data object Loading : ListState data class Error(val message: String) : ListState + data class AuthRequired(val message: String) : ListState data class Loaded(val sections: List) : ListState } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt index c5c13786c..92b1aad75 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -134,18 +135,28 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() { _uiState.update { it.copy(listState = ListState.Loading) } viewModelScope.launch { try { - val schemaDeferred = async { apiClient.getSchema() } - val configDeferred = async { apiClient.getConfig() } - val s = schemaDeferred.await() - val c = configDeferred.await() - schema = s - configValues = c - _uiState.update { - it.copy(listState = ListState.Loaded(s.toSummaries())) + coroutineScope { + val schemaDeferred = async { apiClient.getSchema() } + val configDeferred = async { apiClient.getConfig() } + val s = schemaDeferred.await() + val c = configDeferred.await() + schema = s + configValues = c + _uiState.update { + it.copy(listState = ListState.Loaded(s.toSummaries())) + } + pendingSectionKey?.let { key -> + pendingSectionKey = null + loadSection(key) + } } - pendingSectionKey?.let { key -> - pendingSectionKey = null - loadSection(key) + } catch (e: AuthException) { + _uiState.update { + it.copy( + listState = ListState.AuthRequired( + e.message ?: "Authentication failed" + ) + ) } } catch (e: Exception) { _uiState.update {