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( SettingsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) }, onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) },
onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) }, onNavigateToAppSettings = { navController.navigate(NavRoutes.appSettings()) },
) )
} }
navigation( navigation(
@ -86,6 +86,9 @@ class MainActivity : ComponentActivity() {
onSectionSelected = { sectionKey -> onSectionSelected = { sectionKey ->
navController.navigate("backend_settings/$sectionKey") navController.navigate("backend_settings/$sectionKey")
}, },
onNavigateToAppSettings = {
navController.navigate(NavRoutes.appSettings(localOnly = true))
},
viewModel = viewModel, 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( AppSettingsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
) )

View file

@ -135,6 +135,6 @@ val appModule = module {
// ViewModel // ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AppSettingsViewModel(get(), get()) } viewModel { AppSettingsViewModel(get(), get(), get()) }
viewModel { SetupViewModel(get(), get()) } viewModel { SetupViewModel(get(), get()) }
} }

View file

@ -6,6 +6,9 @@ object NavRoutes {
const val BACKEND_SETTINGS = "backend_settings" const val BACKEND_SETTINGS = "backend_settings"
const val BACKEND_SETTINGS_LIST = "backend_settings_list" const val BACKEND_SETTINGS_LIST = "backend_settings_list"
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" 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" const val SETUP = "setup"
fun appSettings(localOnly: Boolean = false): String =
"app_settings?localOnly=$localOnly"
} }

View file

@ -1,5 +1,6 @@
package io.clawdroid.settings package io.clawdroid.settings
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.clawdroid.backend.api.GatewaySettings import io.clawdroid.backend.api.GatewaySettings
@ -30,10 +31,13 @@ private fun portError(value: String): String? {
} }
class AppSettingsViewModel( class AppSettingsViewModel(
savedStateHandle: SavedStateHandle,
private val settingsStore: GatewaySettingsStore, private val settingsStore: GatewaySettingsStore,
private val configApiClient: ConfigApiClient, private val configApiClient: ConfigApiClient,
) : ViewModel() { ) : ViewModel() {
private val localOnly: Boolean = savedStateHandle["localOnly"] ?: false
private val _uiState = MutableStateFlow(AppSettingsUiState()) private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow()
@ -75,8 +79,9 @@ class AppSettingsViewModel(
} }
try { try {
configApiClient.saveConfig(payload) if (!localOnly) {
// Persist new values locally after remote success configApiClient.saveConfig(payload)
}
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey)) settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
_uiState.update { it.copy(saving = false) } _uiState.update { it.copy(saving = false) }
onComplete() onComplete()

View file

@ -44,6 +44,8 @@ data class SaveConfigResult(
val error: String? = null, val error: String? = null,
) )
class AuthException(message: String) : IOException(message)
class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable { class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
private val baseUrl: String private val baseUrl: String
get() = settingsStore.settings.value.httpBaseUrl get() = settingsStore.settings.value.httpBaseUrl
@ -84,7 +86,9 @@ class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeab
private suspend fun HttpResponse.ensureSuccess(): HttpResponse { private suspend fun HttpResponse.ensureSuccess(): HttpResponse {
if (!status.isSuccess()) { if (!status.isSuccess()) {
val error = runCatching { body<SaveConfigResult>().error }.getOrNull() 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 return this
} }

View file

@ -48,6 +48,7 @@ import io.clawdroid.core.ui.theme.TextSecondary
fun ConfigSectionListScreen( fun ConfigSectionListScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSectionSelected: (sectionKey: String) -> Unit, onSectionSelected: (sectionKey: String) -> Unit,
onNavigateToAppSettings: () -> Unit,
viewModel: ConfigViewModel, viewModel: ConfigViewModel,
) { ) {
val uiState by viewModel.uiState.collectAsState() 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 -> { is ListState.Loaded -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier

View file

@ -9,6 +9,7 @@ data class ConfigUiState(
sealed interface ListState { sealed interface ListState {
data object Loading : ListState data object Loading : ListState
data class Error(val message: String) : ListState data class Error(val message: String) : ListState
data class AuthRequired(val message: String) : ListState
data class Loaded(val sections: List<SectionSummary>) : ListState data class Loaded(val sections: List<SectionSummary>) : ListState
} }

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -134,18 +135,28 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
_uiState.update { it.copy(listState = ListState.Loading) } _uiState.update { it.copy(listState = ListState.Loading) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val schemaDeferred = async { apiClient.getSchema() } coroutineScope {
val configDeferred = async { apiClient.getConfig() } val schemaDeferred = async { apiClient.getSchema() }
val s = schemaDeferred.await() val configDeferred = async { apiClient.getConfig() }
val c = configDeferred.await() val s = schemaDeferred.await()
schema = s val c = configDeferred.await()
configValues = c schema = s
_uiState.update { configValues = c
it.copy(listState = ListState.Loaded(s.toSummaries())) _uiState.update {
it.copy(listState = ListState.Loaded(s.toSummaries()))
}
pendingSectionKey?.let { key ->
pendingSectionKey = null
loadSection(key)
}
} }
pendingSectionKey?.let { key -> } catch (e: AuthException) {
pendingSectionKey = null _uiState.update {
loadSection(key) it.copy(
listState = ListState.AuthRequired(
e.message ?: "Authentication failed"
)
)
} }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { _uiState.update {