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) {
|
synchronized(this) {
|
||||||
if (jar != null) return
|
if (jar != null) return
|
||||||
val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred)
|
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
|
// Eagerly load the initial scope off the caller thread so the
|
||||||
// first REST call is instant without blocking app startup.
|
// first REST call is instant without blocking app startup.
|
||||||
scope.launch { jar!!.useStore(initialServerId) }
|
scope.launch { jar!!.useStore(initialServerId) }
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
class PersistentCookieJar(
|
class PersistentCookieJar(
|
||||||
private val store: CookieStore,
|
private val store: CookieStore,
|
||||||
private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
initialServerId: String = DEFAULT_SERVER_ID,
|
||||||
) : CookieJar {
|
) : CookieJar {
|
||||||
// serverId -> (host -> cookies)
|
// serverId -> (host -> cookies)
|
||||||
private val cache = ConcurrentHashMap<String, MutableMap<String, MutableList<Cookie>>>()
|
private val cache = ConcurrentHashMap<String, MutableMap<String, MutableList<Cookie>>>()
|
||||||
private val loadedScopes = ConcurrentHashMap.newKeySet<String>()
|
private val loadedScopes = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
@Volatile private var currentServerId = AtomicReference(DEFAULT_SERVER_ID)
|
@Volatile private var currentServerId = AtomicReference(initialServerId)
|
||||||
|
|
||||||
private val scopeMutex = Mutex()
|
private val scopeMutex = Mutex()
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ class PersistentCookieJar(
|
|||||||
*/
|
*/
|
||||||
fun getCookie(name: String): Cookie? {
|
fun getCookie(name: String): Cookie? {
|
||||||
val serverId = currentServerId.get()
|
val serverId = currentServerId.get()
|
||||||
|
if (!loadedScopes.contains(serverId)) {
|
||||||
|
kotlinx.coroutines.runBlocking { ensureLoaded(serverId) }
|
||||||
|
}
|
||||||
val hosts = cache[serverId] ?: return null
|
val hosts = cache[serverId] ?: return null
|
||||||
for (bucket in hosts.values) {
|
for (bucket in hosts.values) {
|
||||||
synchronized(bucket) {
|
synchronized(bucket) {
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ object HermesWsClient {
|
|||||||
intentionalClose.set(false)
|
intentionalClose.set(false)
|
||||||
currentBackoff = INITIAL_BACKOFF_MS
|
currentBackoff = INITIAL_BACKOFF_MS
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||||
refreshWsTicketIfNeeded()
|
|
||||||
openSocket()
|
openSocket()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,12 +199,24 @@ object HermesWsClient {
|
|||||||
*
|
*
|
||||||
* The session cookie is attached automatically by the shared CookieJar on
|
* The session cookie is attached automatically by the shared CookieJar on
|
||||||
* OkHttpProvider.probe (issue #470), so we no longer inject it manually.
|
* 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()
|
val sessionCookie = AuthManager.getSessionCookie()
|
||||||
if (sessionCookie.isNullOrBlank()) {
|
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 {
|
try {
|
||||||
val client = OkHttpProvider.probe
|
val client = OkHttpProvider.probe
|
||||||
val request =
|
val request =
|
||||||
@@ -214,20 +225,35 @@ object HermesWsClient {
|
|||||||
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
|
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
|
||||||
.post("{}".toRequestBody())
|
.post("{}".toRequestBody())
|
||||||
.build()
|
.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) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body?.string() ?: ""
|
val body = response.body?.string() ?: ""
|
||||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\""").find(body)
|
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||||
if (!ticket.isNullOrBlank()) {
|
if (!ticket.isNullOrBlank()) {
|
||||||
AuthManager.setToken(ticket)
|
AuthManager.setToken(ticket)
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
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 {
|
} else {
|
||||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
|
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +401,10 @@ object HermesWsClient {
|
|||||||
// ── Internal ─────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun openSocket() {
|
private fun openSocket() {
|
||||||
refreshWsTicketIfNeeded()
|
if (!refreshWsTicketIfNeeded()) {
|
||||||
|
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
val url = AuthManager.wsUrl()
|
val url = AuthManager.wsUrl()
|
||||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
@@ -479,7 +508,10 @@ object HermesWsClient {
|
|||||||
Log.i(TAG, "WebSocket closed: $code $reason")
|
Log.i(TAG, "WebSocket closed: $code $reason")
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
stopHealthTracking()
|
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
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
} else {
|
} else {
|
||||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ class ChannelsViewModel :
|
|||||||
onboardingExpiresIn = formatExpiry(setup?.expiresAt),
|
onboardingExpiresIn = formatExpiry(setup?.expiresAt),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||||
it.copy(
|
it.copy(
|
||||||
onboardingPhase = OnboardingPhase.IDLE,
|
onboardingPhase = OnboardingPhase.IDLE,
|
||||||
@@ -350,6 +351,7 @@ class ChannelsViewModel :
|
|||||||
}
|
}
|
||||||
_uiState.update { it.copy(onboardingError = null) }
|
_uiState.update { it.copy(onboardingError = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||||
val expired = isOnboardingExpired(_uiState.value.onboardingSetup)
|
val expired = isOnboardingExpired(_uiState.value.onboardingSetup)
|
||||||
if (expired) {
|
if (expired) {
|
||||||
@@ -470,6 +472,7 @@ class ChannelsViewModel :
|
|||||||
loadPlatforms()
|
loadPlatforms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -520,7 +523,9 @@ private fun formatExpiry(expiresAt: String?): String {
|
|||||||
val ms =
|
val ms =
|
||||||
try {
|
try {
|
||||||
isoTimestampParser
|
isoTimestampParser
|
||||||
.parse(expiresAt)?.time?.minus(System.currentTimeMillis())
|
.parse(expiresAt)
|
||||||
|
?.time
|
||||||
|
?.minus(System.currentTimeMillis())
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -537,7 +542,8 @@ private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.Telegram
|
|||||||
val ms =
|
val ms =
|
||||||
try {
|
try {
|
||||||
isoTimestampParser
|
isoTimestampParser
|
||||||
.parse(expiresAt)?.time
|
.parse(expiresAt)
|
||||||
|
?.time
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -186,6 +186,7 @@ internal fun TelegramOnboardingDialog(
|
|||||||
Text("Save and restart")
|
Text("Save and restart")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -194,9 +195,11 @@ internal fun TelegramOnboardingDialog(
|
|||||||
OnboardingPhase.READY -> {
|
OnboardingPhase.READY -> {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingPhase.WAITING -> {
|
OnboardingPhase.WAITING -> {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
|
is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
|
is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event)
|
is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event)
|
is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.MessageComplete -> onMessageComplete(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)
|
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
|
||||||
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ class KeysViewModel :
|
|||||||
|
|
||||||
private fun buildCategoryList(
|
private fun buildCategoryList(
|
||||||
envVars: Map<String, EnvVarConfig>,
|
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> {
|
): List<CategorySection> {
|
||||||
// Group by category, uncategorized go to "Other"
|
// Group by category, uncategorized go to "Other"
|
||||||
val grouped = mutableMapOf<String, MutableMap<String, EnvVarConfig>>()
|
val grouped = mutableMapOf<String, MutableMap<String, EnvVarConfig>>()
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ fun McpServersScreen(
|
|||||||
state.isLoading && state.servers.isEmpty() -> {
|
state.isLoading && state.servers.isEmpty() -> {
|
||||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
@@ -126,6 +127,7 @@ fun McpServersScreen(
|
|||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -557,12 +559,14 @@ private fun CatalogSection(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(spacing.sm))
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.catalogError != null -> {
|
state.catalogError != null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.catalogError ?: "",
|
message = state.catalogError ?: "",
|
||||||
onRetry = { viewModel.loadCatalog() },
|
onRetry = { viewModel.loadCatalog() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredCatalog.isEmpty() -> {
|
filteredCatalog.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.mcp_servers_catalog_empty),
|
text = stringResource(R.string.mcp_servers_catalog_empty),
|
||||||
@@ -571,6 +575,7 @@ private fun CatalogSection(
|
|||||||
modifier = Modifier.padding(vertical = spacing.md),
|
modifier = Modifier.padding(vertical = spacing.md),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
filteredCatalog.forEach { entry ->
|
filteredCatalog.forEach { entry ->
|
||||||
CatalogEntryCard(
|
CatalogEntryCard(
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class McpServersViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
||||||
}
|
}
|
||||||
@@ -130,6 +131,7 @@ class McpServersViewModel :
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
|
_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") }
|
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") }
|
_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") }
|
_uiState.update { it.copy(toastMessage = "Server '$name' restarted") }
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
@@ -220,7 +224,10 @@ class McpServersViewModel :
|
|||||||
},
|
},
|
||||||
args =
|
args =
|
||||||
if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) {
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -244,6 +251,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -300,6 +308,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") }
|
_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") }
|
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
@@ -349,6 +359,7 @@ class McpServersViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -389,6 +400,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ enum class SkillsViewMode {
|
|||||||
HUB,
|
HUB,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class SkillFilter(val labelRes: Int) {
|
enum class SkillFilter(
|
||||||
|
val labelRes: Int,
|
||||||
|
) {
|
||||||
ALL_STATUSES(R.string.skills_status_all),
|
ALL_STATUSES(R.string.skills_status_all),
|
||||||
ENABLED(R.string.skills_status_enabled),
|
ENABLED(R.string.skills_status_enabled),
|
||||||
DISABLED(R.string.skills_status_disabled),
|
DISABLED(R.string.skills_status_disabled),
|
||||||
@@ -74,8 +76,9 @@ data class SkillsUiState(
|
|||||||
val sourceFilter: String? = null,
|
val sourceFilter: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SkillsViewModel(application: Application) :
|
class SkillsViewModel(
|
||||||
AndroidViewModel(application),
|
application: Application,
|
||||||
|
) : AndroidViewModel(application),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(SkillsUiState())
|
private val _uiState = MutableStateFlow(SkillsUiState())
|
||||||
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
|
||||||
@@ -189,6 +192,7 @@ class SkillsViewModel(application: Application) :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -238,6 +242,7 @@ class SkillsViewModel(application: Application) :
|
|||||||
// Refresh installed skills
|
// Refresh installed skills
|
||||||
loadSkills()
|
loadSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -274,6 +279,7 @@ class SkillsViewModel(application: Application) :
|
|||||||
}
|
}
|
||||||
loadSkills()
|
loadSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -311,6 +317,7 @@ class SkillsViewModel(application: Application) :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -469,6 +476,7 @@ class SkillsViewModel(application: Application) :
|
|||||||
_uiState.update { it.copy(toastMessage = "Skills updated") }
|
_uiState.update { it.copy(toastMessage = "Skills updated") }
|
||||||
loadSkills()
|
loadSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,12 +509,14 @@ class ChatViewModelTest {
|
|||||||
assertEquals(2, viewModel.uiState.value.messages.size)
|
assertEquals(2, viewModel.uiState.value.messages.size)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"The answer is 42",
|
"The answer is 42",
|
||||||
viewModel.uiState.value.messages[1].content,
|
viewModel.uiState.value.messages[1]
|
||||||
|
.content,
|
||||||
)
|
)
|
||||||
// reasoning carried onto the finalized UI message
|
// reasoning carried onto the finalized UI message
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Let me think step by step",
|
"Let me think step by step",
|
||||||
viewModel.uiState.value.messages[1].reasoningText,
|
viewModel.uiState.value.messages[1]
|
||||||
|
.reasoningText,
|
||||||
)
|
)
|
||||||
// reasoning persisted to the entity (survives reload)
|
// reasoning persisted to the entity (survives reload)
|
||||||
val persisted =
|
val persisted =
|
||||||
@@ -804,8 +806,16 @@ class ChatViewModelTest {
|
|||||||
viewModel.removeAttachment(1)
|
viewModel.removeAttachment(1)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(2, viewModel.uiState.value.pendingAttachments.size)
|
assertEquals(2, viewModel.uiState.value.pendingAttachments.size)
|
||||||
assertEquals("uri0", viewModel.uiState.value.pendingAttachments[0].uri)
|
assertEquals(
|
||||||
assertEquals("uri2", viewModel.uiState.value.pendingAttachments[1].uri)
|
"uri0",
|
||||||
|
viewModel.uiState.value.pendingAttachments[0]
|
||||||
|
.uri,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"uri2",
|
||||||
|
viewModel.uiState.value.pendingAttachments[1]
|
||||||
|
.uri,
|
||||||
|
)
|
||||||
|
|
||||||
// 2. Fire invalid removals (out of bounds + negative) — must be no-ops.
|
// 2. Fire invalid removals (out of bounds + negative) — must be no-ops.
|
||||||
viewModel.removeAttachment(99)
|
viewModel.removeAttachment(99)
|
||||||
@@ -1290,8 +1300,10 @@ class ChatViewModelTest {
|
|||||||
// Graceful handling: the message is still sent even though the
|
// Graceful handling: the message is still sent even though the
|
||||||
// attachment bytes couldn't be read (no crash, no lost message).
|
// attachment bytes couldn't be read (no crash, no lost message).
|
||||||
val sent =
|
val sent =
|
||||||
viewModel.uiState.value.messages.firstOrNull { it.id == sessionId } != null ||
|
viewModel.uiState.value.messages
|
||||||
viewModel.uiState.value.messages.any { it.content == "Here is an image" }
|
.firstOrNull { it.id == sessionId } != null ||
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.any { it.content == "Here is an image" }
|
||||||
assertTrue("Message should still be sent despite attachment read failure", sent)
|
assertTrue("Message should still be sent despite attachment read failure", sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class GatewayViewModelTest {
|
|||||||
|
|
||||||
assertFalse(viewModel.uiState.value.isLoading)
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
assertNull(viewModel.uiState.value.status)
|
assertNull(viewModel.uiState.value.status)
|
||||||
assertTrue(viewModel.uiState.value.errorMessage?.contains("500") == true)
|
assertTrue(
|
||||||
|
viewModel.uiState.value.errorMessage
|
||||||
|
?.contains("500") == true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,6 +107,9 @@ class GatewayViewModelTest {
|
|||||||
|
|
||||||
assertFalse(viewModel.uiState.value.isLoading)
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
assertNull(viewModel.uiState.value.status)
|
assertNull(viewModel.uiState.value.status)
|
||||||
assertTrue(viewModel.uiState.value.errorMessage?.contains("Network timeout") == true)
|
assertTrue(
|
||||||
|
viewModel.uiState.value.errorMessage
|
||||||
|
?.contains("Network timeout") == true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user