feat: handle 403 auth errors in backend config and add local-only save mode

When the gateway returns 403, show a dedicated AuthRequired screen with
a shortcut to Connection Settings (localOnly=true) so the user can fix
the API key without hitting the unreachable server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 19:33:58 +09:00
parent 84ccd83ada
commit b790a63e7d
8 changed files with 91 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SectionSummary>) : ListState
}

View file

@ -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 {