Fix gated close codes, run ticket refresh on Dispatchers.IO, prevent scope race on startup, and auto-format files.
This commit is contained in:
12 files changed
+118
-24
No files matched your search
@@ -45,7 +45,7 @@ object CookieManager {
|
||||
synchronized(this) {
|
||||
if (jar != null) return
|
||||
val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred)
|
||||
jar = PersistentCookieJar(store, scope)
|
||||
jar = PersistentCookieJar(store, scope, initialServerId)
|
||||
// Eagerly load the initial scope off the caller thread so the
|
||||
// first REST call is instant without blocking app startup.
|
||||
scope.launch { jar!!.useStore(initialServerId) }
|
||||
|
||||
@@ -33,12 +33,13 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
class PersistentCookieJar(
|
||||
private val store: CookieStore,
|
||||
private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
initialServerId: String = DEFAULT_SERVER_ID,
|
||||
) : CookieJar {
|
||||
// serverId -> (host -> cookies)
|
||||
private val cache = ConcurrentHashMap<String, MutableMap<String, MutableList<Cookie>>>()
|
||||
private val loadedScopes = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
@Volatile private var currentServerId = AtomicReference(DEFAULT_SERVER_ID)
|
||||
@Volatile private var currentServerId = AtomicReference(initialServerId)
|
||||
|
||||
private val scopeMutex = Mutex()
|
||||
|
||||
@@ -60,6 +61,9 @@ class PersistentCookieJar(
|
||||
*/
|
||||
fun getCookie(name: String): Cookie? {
|
||||
val serverId = currentServerId.get()
|
||||
if (!loadedScopes.contains(serverId)) {
|
||||
kotlinx.coroutines.runBlocking { ensureLoaded(serverId) }
|
||||
}
|
||||
val hosts = cache[serverId] ?: return null
|
||||
for (bucket in hosts.values) {
|
||||
synchronized(bucket) {
|
||||
|
||||
@@ -189,7 +189,6 @@ object HermesWsClient {
|
||||
intentionalClose.set(false)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||
refreshWsTicketIfNeeded()
|
||||
openSocket()
|
||||
}
|
||||
|
||||
@@ -200,12 +199,24 @@ object HermesWsClient {
|
||||
*
|
||||
* The session cookie is attached automatically by the shared CookieJar on
|
||||
* OkHttpProvider.probe (issue #470), so we no longer inject it manually.
|
||||
*
|
||||
* Returns true if the token is ready (either because we are not in gated mode,
|
||||
* or because ticket refresh succeeded). Returns false if we are in gated mode
|
||||
* and ticket refresh failed or cannot be performed.
|
||||
*/
|
||||
private fun refreshWsTicketIfNeeded() {
|
||||
private fun refreshWsTicketIfNeeded(): Boolean {
|
||||
val isGated = AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||
if (!isGated) {
|
||||
return true
|
||||
}
|
||||
|
||||
val sessionCookie = AuthManager.getSessionCookie()
|
||||
if (sessionCookie.isNullOrBlank()) {
|
||||
return
|
||||
Log.w(TAG, "WS ticket refresh aborted: no session cookie found in gated mode")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val client = OkHttpProvider.probe
|
||||
val request =
|
||||
@@ -214,20 +225,35 @@ object HermesWsClient {
|
||||
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
|
||||
.post("{}".toRequestBody())
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Run network call on Dispatchers.IO to avoid NetworkOnMainThreadException
|
||||
val response =
|
||||
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||
client.newCall(request).execute()
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string() ?: ""
|
||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\""").find(body)
|
||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
AuthManager.setToken(ticket)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +401,10 @@ object HermesWsClient {
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun openSocket() {
|
||||
refreshWsTicketIfNeeded()
|
||||
if (!refreshWsTicketIfNeeded()) {
|
||||
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||
return
|
||||
}
|
||||
val url = AuthManager.wsUrl()
|
||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
@@ -479,7 +508,10 @@ object HermesWsClient {
|
||||
Log.i(TAG, "WebSocket closed: $code $reason")
|
||||
connected.set(false)
|
||||
stopHealthTracking()
|
||||
if (code == 4001 || reason.contains("unauthorized", ignoreCase = true)) {
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
|
||||
@@ -301,6 +301,7 @@ class ChannelsViewModel :
|
||||
onboardingExpiresIn = formatExpiry(setup?.expiresAt),
|
||||
)
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.IDLE,
|
||||
@@ -350,6 +351,7 @@ class ChannelsViewModel :
|
||||
}
|
||||
_uiState.update { it.copy(onboardingError = null) }
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
val expired = isOnboardingExpired(_uiState.value.onboardingSetup)
|
||||
if (expired) {
|
||||
@@ -470,6 +472,7 @@ class ChannelsViewModel :
|
||||
loadPlatforms()
|
||||
}
|
||||
}
|
||||
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -520,7 +523,9 @@ private fun formatExpiry(expiresAt: String?): String {
|
||||
val ms =
|
||||
try {
|
||||
isoTimestampParser
|
||||
.parse(expiresAt)?.time?.minus(System.currentTimeMillis())
|
||||
.parse(expiresAt)
|
||||
?.time
|
||||
?.minus(System.currentTimeMillis())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -537,7 +542,8 @@ private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.Telegram
|
||||
val ms =
|
||||
try {
|
||||
isoTimestampParser
|
||||
.parse(expiresAt)?.time
|
||||
.parse(expiresAt)
|
||||
?.time
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
+3
@@ -186,6 +186,7 @@ internal fun TelegramOnboardingDialog(
|
||||
Text("Save and restart")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
@@ -194,9 +195,11 @@ internal fun TelegramOnboardingDialog(
|
||||
OnboardingPhase.READY -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
|
||||
OnboardingPhase.WAITING -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
|
||||
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -54,7 +54,9 @@ object ChatWsEventReducer {
|
||||
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
|
||||
|
||||
is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
|
||||
|
||||
is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event)
|
||||
|
||||
is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event)
|
||||
|
||||
is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event)
|
||||
@@ -86,6 +88,7 @@ object ChatWsEventReducer {
|
||||
|
||||
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
|
||||
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,10 @@ class KeysViewModel :
|
||||
|
||||
private fun buildCategoryList(
|
||||
envVars: Map<String, EnvVarConfig>,
|
||||
expandedCategories: Set<String> = _uiState.value.categories.map { it.name }.toSet(),
|
||||
expandedCategories: Set<String> =
|
||||
_uiState.value.categories
|
||||
.map { it.name }
|
||||
.toSet(),
|
||||
): List<CategorySection> {
|
||||
// Group by category, uncategorized go to "Other"
|
||||
val grouped = mutableMapOf<String, MutableMap<String, EnvVarConfig>>()
|
||||
|
||||
@@ -119,6 +119,7 @@ fun McpServersScreen(
|
||||
state.isLoading && state.servers.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
@@ -126,6 +127,7 @@ fun McpServersScreen(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -557,12 +559,14 @@ private fun CatalogSection(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
}
|
||||
|
||||
state.catalogError != null -> {
|
||||
ErrorState(
|
||||
message = state.catalogError ?: "",
|
||||
onRetry = { viewModel.loadCatalog() },
|
||||
)
|
||||
}
|
||||
|
||||
filteredCatalog.isEmpty() -> {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_catalog_empty),
|
||||
@@ -571,6 +575,7 @@ private fun CatalogSection(
|
||||
modifier = Modifier.padding(vertical = spacing.md),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
filteredCatalog.forEach { entry ->
|
||||
CatalogEntryCard(
|
||||
|
||||
@@ -112,6 +112,7 @@ class McpServersViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
||||
}
|
||||
@@ -130,6 +131,7 @@ class McpServersViewModel :
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
|
||||
}
|
||||
@@ -148,6 +150,7 @@ class McpServersViewModel :
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") }
|
||||
}
|
||||
@@ -167,6 +170,7 @@ class McpServersViewModel :
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' restarted") }
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") }
|
||||
}
|
||||
@@ -220,7 +224,10 @@ class McpServersViewModel :
|
||||
},
|
||||
args =
|
||||
if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) {
|
||||
state.addServerArgs.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
|
||||
state.addServerArgs
|
||||
.trim()
|
||||
.split("\\s+".toRegex())
|
||||
.filter { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -244,6 +251,7 @@ class McpServersViewModel :
|
||||
}
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -300,6 +308,7 @@ class McpServersViewModel :
|
||||
}
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") }
|
||||
}
|
||||
@@ -324,6 +333,7 @@ class McpServersViewModel :
|
||||
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") }
|
||||
}
|
||||
@@ -349,6 +359,7 @@ class McpServersViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -389,6 +400,7 @@ class McpServersViewModel :
|
||||
}
|
||||
loadServers()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
||||
@@ -29,7 +29,9 @@ enum class SkillsViewMode {
|
||||
HUB,
|
||||
}
|
||||
|
||||
enum class SkillFilter(val labelRes: Int) {
|
||||
enum class SkillFilter(
|
||||
val labelRes: Int,
|
||||
) {
|
||||
ALL_STATUSES(R.string.skills_status_all),
|
||||
ENABLED(R.string.skills_status_enabled),
|
||||
DISABLED(R.string.skills_status_disabled),
|
||||
@@ -74,8 +76,9 @@ data class SkillsUiState(
|
||||
val sourceFilter: String? = null,
|
||||
)
|
||||
|
||||
class SkillsViewModel(application: Application) :
|
||||
AndroidViewModel(application),
|
||||
class SkillsViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(SkillsUiState())
|
||||
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
|
||||
@@ -189,6 +192,7 @@ class SkillsViewModel(application: Application) :
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -238,6 +242,7 @@ class SkillsViewModel(application: Application) :
|
||||
// Refresh installed skills
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -274,6 +279,7 @@ class SkillsViewModel(application: Application) :
|
||||
}
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -311,6 +317,7 @@ class SkillsViewModel(application: Application) :
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -469,6 +476,7 @@ class SkillsViewModel(application: Application) :
|
||||
_uiState.update { it.copy(toastMessage = "Skills updated") }
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user