perf: optimize screen load times with progressive un-gating and SWR caching (#1192)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 23:25:30 +03:00
1 parent abce60ad7a
commit f5eaecbbef
55 files changed
+2494 -401

No files matched your search

@@ -10,6 +10,7 @@ import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
@@ -29,6 +30,7 @@ class HermesControlApp :
super.onCreate()
AuthManager.init(this)
NetworkMonitor.init(this)
SessionListCacheStore.init(this)
appScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
// Issue #537 follow-up (A): preload analytics in the background after launch
@@ -127,6 +127,41 @@ object AuthManager {
initialValue = null,
)
private val _authGenerationFlow = MutableStateFlow(0L)
val authGenerationFlow: StateFlow<Long> = _authGenerationFlow.asStateFlow()
fun invalidateAuthGeneration() {
_authGenerationFlow.value = _authGenerationFlow.value + 1
}
/**
* Canonical observable data-scope identity covering connection profile, base URL,
* active server-side Hermes profile, and in-memory auth generation.
*/
val dataScopeFlow: StateFlow<DataScope?> =
combine(
selectedProfileFlow,
baseUrlFlow,
activeProfileId,
authGenerationFlow,
) { selectedProfile, baseUrl, activeProfile, authGen ->
val cleanUrl = baseUrl.trimEnd('/')
if (cleanUrl.isBlank()) {
null
} else {
DataScope(
connectionProfileId = selectedProfile ?: DEFAULT_PROFILE_ID,
baseUrl = cleanUrl,
activeProfileId = activeProfile?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = authGen,
)
}
}.stateIn(
scope = contextScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -595,6 +630,9 @@ object AuthManager {
}
fun setToken(token: String?) {
if (token == null) {
invalidateAuthGeneration()
}
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -611,6 +649,23 @@ object AuthManager {
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun currentDataScope(): DataScope? {
val rawUrl = runCatching { getBaseUrl() }.getOrNull() ?: _baseUrlFlow.value
val cleanUrl = rawUrl.trimEnd('/')
if (cleanUrl.isBlank()) return null
val connectionProfileId =
runCatching { getSelectedProfileId() }.getOrNull()
?: _selectedProfileFlow.value
?: DEFAULT_PROFILE_ID
val activeProfile = _activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
return DataScope(
connectionProfileId = connectionProfileId,
baseUrl = cleanUrl,
activeProfileId = activeProfile,
inMemoryAuthGeneration = _authGenerationFlow.value,
)
}
fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.data.local
/**
* Canonical data-scope identity representing the authenticated server and profile context.
* Used to partition caches (both in-memory and persistent) so that switching servers,
* changing server URLs, switching server-side profiles, or logging out/re-logging in
* never leaks data across boundaries.
*
* Contains no credentials, tokens, cookies, passwords, or secrets.
*/
data class DataScope(
val connectionProfileId: String,
val baseUrl: String,
val activeProfileId: String,
val inMemoryAuthGeneration: Long = 0L,
) {
/**
* Deterministic in-memory cache key.
* Uses length-prefixing for unambiguous separation of components, and includes
* [inMemoryAuthGeneration] so logging out and re-logging in automatically invalidates
* all in-memory caches.
*/
fun inMemoryKey(localKey: String = "default"): String = "g$inMemoryAuthGeneration:${persistentKey(localKey)}"
/**
* Deterministic persistent cache key for disk storage (e.g. SessionListCacheStore).
* Uses length-prefixing so no delimiter collision is possible.
* Does NOT include volatile [inMemoryAuthGeneration] so disk caches survive normal process restarts.
* Disk caches are explicitly wiped on logout.
*/
fun persistentKey(localKey: String = "default"): String =
"c${connectionProfileId.length}:$connectionProfileId:u${baseUrl.length}:$baseUrl:p${activeProfileId.length}:$activeProfileId:k${localKey.length}:$localKey"
fun scopedKey(localKey: String = "default"): String = inMemoryKey(localKey)
companion object {
val EMPTY =
DataScope(
connectionProfileId = AuthManager.DEFAULT_PROFILE_ID,
baseUrl = "",
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = 0L,
)
}
}
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
/**
* Lightweight disk cache for the initial page of sessions.
* Persists the first page JSON to SharedPreferences so the History screen
* can render instantly even after app kill / process death.
*/
object SessionListCacheStore {
private const val PREFS_NAME = "hermes_sessions_cache"
private const val KEY_PREFIX = "sessions_page_"
@Volatile
private var prefs: SharedPreferences? = null
fun init(context: Context) {
if (prefs == null) {
synchronized(this) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
}
}
fun get(key: String): SessionListResponse? {
val p = prefs ?: return null
val raw = p.getString(KEY_PREFIX + key, null) ?: return null
return try {
OkHttpProvider.json.decodeFromString<SessionListResponse>(raw)
} catch (_: Exception) {
null
}
}
fun put(
key: String,
data: SessionListResponse,
) {
val p = prefs ?: return
try {
val json = OkHttpProvider.json.encodeToString(SessionListResponse.serializer(), data)
p.edit().putString(KEY_PREFIX + key, json).apply()
} catch (_: Exception) {
// Ignore serialization issues on cache save
}
}
fun remove(key: String) {
prefs?.edit()?.remove(KEY_PREFIX + key)?.apply()
}
fun clear() {
prefs?.edit()?.clear()?.apply()
}
@VisibleForTesting
internal fun resetForTesting(testPrefs: SharedPreferences? = null) {
synchronized(this) {
prefs = testPrefs
}
}
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.data.local
/**
* Lightweight thread-safe in-memory cache for Stale-While-Revalidate screen state.
* Stores entries with optional TTL and a bounded capacity.
*
* All map operations are synchronized on a private lock so that compound
* operations (e.g. read + TTL expiry check + remove) are atomic and safe against races.
*/
class SwrCache<K : Any, V : Any>(
private val maxCapacity: Int = DEFAULT_MAX_CAPACITY,
private val ttlMillis: Long = DEFAULT_TTL_MILLIS,
private val timeProvider: () -> Long = { System.currentTimeMillis() },
) {
private data class Entry<V>(
val value: V,
val timestamp: Long,
)
private val lock = Any()
private val map =
object : LinkedHashMap<K, Entry<V>>(maxCapacity, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, Entry<V>>?): Boolean = size > maxCapacity
}
fun get(key: K): V? =
synchronized(lock) {
val entry = map[key] ?: return null
if (ttlMillis > 0 && timeProvider() - entry.timestamp > ttlMillis) {
map.remove(key)
return null
}
entry.value
}
fun put(
key: K,
value: V,
) {
synchronized(lock) {
map[key] = Entry(value, timeProvider())
}
}
fun remove(key: K): V? =
synchronized(lock) {
map.remove(key)?.value
}
fun clear() {
synchronized(lock) {
map.clear()
}
}
val size: Int
get() = synchronized(lock) { map.size }
companion object {
const val DEFAULT_MAX_CAPACITY = 50
const val DEFAULT_TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
}
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
return NetworkResult.Failure(
NetworkError.Unknown(
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -31,6 +32,18 @@ import kotlinx.coroutines.withContext
* 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
*/
object ProfileSwitchCoordinator {
/**
* Dispatcher for the blocking network hops below.
*
* Injectable so tests can drive the whole switch on a TestDispatcher. With the
* real Dispatchers.IO these paths hop to a live thread pool and the ORDER of
* the mocked calls becomes load-dependent -- ProfileSwitchCoordinatorTest's
* Ordering.SEQUENCE checks passed on an idle machine but lost 2 tests while the
* emulator saturated the CPU, and its setMain-less sibling tests failed
* outright whenever another class had left Dispatchers.Main broken.
*/
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
val switched: SharedFlow<String> = _switched.asSharedFlow()
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
suspend fun switchProfile(name: String): NetworkResult<Unit> {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
}
if (result !is NetworkResult.Success) return result
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
// run off the main thread or the dial crashes with
// NetworkOnMainThreadException and falls back to the 1s reconnect
// retry (visible in the 2026-08-06 live logcat).
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
HermesWsClient.disconnect()
HermesWsClient.connect()
}
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
AuthManager.setSelectedProfileId(profileId)
ApiClient.rebuild()
_connectionSwitched.emit(profileId.orEmpty())
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
// The WS ticket mint reads the cookie jar's ACTIVE store; the
// selection change swaps that store asynchronously, so a dial
// that races it mints with the PREVIOUS server's cookie → 401 →
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.ui.common.EmptyState
@@ -72,6 +73,7 @@ fun AchievementsScreen(
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showResetDialog by remember { mutableStateOf(false) }
@@ -92,7 +94,8 @@ fun AchievementsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadAchievements()
viewModel.loadRecentUnlocks()
}
@@ -129,7 +132,10 @@ fun AchievementsScreen(
title = { Text(stringResource(R.string.screen_achievements)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() },
onRefresh = {
viewModel.loadAchievements(forceRefresh = true)
viewModel.loadRecentUnlocks()
},
modifier = modifier,
actions = {
Box {
@@ -1,12 +1,16 @@
package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -46,9 +50,53 @@ class AchievementsViewModel :
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
private val api get() = ApiClient.hermesApi
private val achievementsCache = SwrCache<String, AchievementsResponse>()
fun loadAchievements() {
safeLaunchLoad(
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
achievements = emptyList(),
unlockedCount = 0,
discoveredCount = 0,
secretCount = 0,
totalCount = 0,
categories = emptyList(),
recentUnlocks = emptyList(),
errorMessage = null,
)
}
}
fun loadAchievements(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = achievementsCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
val categories =
data.achievements
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
_uiState.update {
it.copy(
isLoading = false,
achievements = data.achievements.orEmpty(),
unlockedCount = data.unlockedCount,
discoveredCount = data.discoveredCount,
secretCount = data.secretCount,
totalCount = data.totalCount,
isStale = data.isStale,
generatedAt = data.generatedAt,
scanState = data.scanMeta?.status?.state ?: "idle",
scanLastError = data.scanMeta?.status?.lastError,
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
categories = categories,
errorMessage = null,
)
}
},
apiCall = { safeApiCall { api.getAchievements() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -100,6 +148,7 @@ class AchievementsViewModel :
}
fun rescan() {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
safeLaunchLoad(
apiCall = { safeApiCall { api.rescanAchievements() } },
onStart = {
@@ -111,6 +160,12 @@ class AchievementsViewModel :
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
// A successful rescan is a mutation: publish the fresh payload into the
// scoped cache, otherwise the next SWR revisit replays the pre-rescan list.
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope) {
achievementsCache.put(requestScope.scopedKey("default"), data)
}
_uiState.update {
it.copy(
isRescanning = false,
@@ -155,7 +210,7 @@ class AchievementsViewModel :
toastMessage = "Achievement state reset",
)
}
loadAchievements()
loadAchievements(forceRefresh = true)
},
onError = { errorMsg ->
_uiState.update {
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -235,68 +237,72 @@ class BotsViewModel(
}
}
viewModelScope.launch(ioDispatcher) {
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
coroutineScope {
val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
}
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val activeResult = activeDeferred.await()
val profilesResult =
if (profilesWithMeta != null) {
null
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesWithMeta != null) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
}
}
}
}
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -34,6 +38,69 @@ inline fun <T> ViewModel.safeLaunchLoad(
}
}
/**
* Executes a read-only API call with Stale-While-Revalidate caching.
* If cached data exists in [cache] for [cacheKey] and [forceRefresh] is false, [onCacheHit] is invoked immediately
* on the caller thread, avoiding full-screen loading spinners on screen revisits.
* The network call runs in the background to update the cache and invoke [onSuccess].
* If a previous request is active:
* - when [forceRefresh] is false, the active job is returned to avoid duplicate in-flight work
* - when [forceRefresh] is true, the active job is cancelled and superseded by the new forced refresh
* Results are checked against [requestScope] and coroutine cancellation so late-arriving responses
* cannot overwrite newer state.
*/
inline fun <T : Any> ViewModel.safeLaunchSwrLoad(
cache: SwrCache<String, T>,
cacheKey: String = "default",
forceRefresh: Boolean = false,
currentJob: Job? = null,
requestScope: DataScope? = runCatching { AuthManager.currentDataScope() }.getOrNull(),
crossinline scopeIsCurrent: () -> Boolean = {
requestScope == null || AuthManager.currentDataScope() == requestScope
},
crossinline onCacheHit: (T) -> Unit,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
): Job {
if (currentJob?.isActive == true) {
if (!forceRefresh) return currentJob
currentJob.cancel()
}
val scopedKey = requestScope?.scopedKey(cacheKey)
if (forceRefresh && scopedKey != null) {
cache.remove(scopedKey)
}
val cached = if (!forceRefresh && scopedKey != null) cache.get(scopedKey) else null
if (cached != null) {
onCacheHit(cached)
} else {
onStart()
}
return viewModelScope.launch {
val result = apiCall()
coroutineContext.ensureActive()
if (!scopeIsCurrent()) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
if (scopedKey != null) {
cache.put(scopedKey, result.data)
}
onSuccess(result.data)
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
onError(result.error.message)
}
}
}
}
}
/**
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
.collect { _ ->
if (refreshInFlight) return@collect
refreshInFlight = true
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
try {
// No Dispatchers.IO hop — see safeLaunchLoad.
val result = apiCall()
coroutineContext.ensureActive()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@collect
if (result is NetworkResult.Success) {
onSuccess(result.data)
}
@@ -99,7 +99,7 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAll() },
onRefresh = { viewModel.loadAll(forceRefresh = true) },
actions = {
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
IconButton(onClick = { viewModel.saveConfig() }) {
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -50,35 +54,114 @@ class ConfigViewModel :
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
private val pendingChanges = mutableMapOf<String, JsonElement>()
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
init {
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
// connection switch). An additional AuthManager.dataScopeFlow collector would
// double-fire on the same transition and issue a duplicate loadAll().
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
loadAll()
}
fun loadAll() {
viewModelScope.launch {
private fun onDataScopeChanged(newScope: DataScope?) {
pendingChanges.clear()
_uiState.update {
it.copy(
values = null,
schema = null,
defaults = null,
uncoveredPaths = emptyList(),
path = null,
yamlText = null,
modifiedKeys = emptySet(),
isSaving = false,
yamlIsSaving = false,
yamlIsLoading = false,
errorMessage = null,
)
}
// A scope switch must load the new context. The scoped caches mean this paints the
// new scope's own entry instantly when one exists, then revalidates in the background.
loadAll()
}
fun loadAll(forceRefresh: Boolean = false) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
if (forceRefresh && scopeKey.isNotBlank()) {
schemaCache.remove(scopeKey)
defaultsCache.remove(scopeKey)
}
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
val hasCachedData = _uiState.value.values != null
if (!hasCachedData) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try {
coroutineScope {
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
val schemaDeferred =
if (cachedSchema == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
} else {
null
}
val defaultsDeferred =
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
if (cachedDefaults == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
} else {
null
}
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await()
val defaultsResult = defaultsDeferred.await()
val schema: ConfigSchemaResponse? =
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
} ?: cachedSchema
val defaults: Map<String, JsonElement>? =
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
} ?: cachedDefaults
val rawResult = rawDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@coroutineScope
if (configResult is NetworkResult.Success) {
val values = flattenConfig(configResult.data)
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update {
it.copy(
_uiState.update { state ->
val categories = schema?.category_order ?: emptyList()
val validCategory =
when {
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
state.activeCategory
}
categories.isNotEmpty() -> {
categories.first()
}
else -> {
state.activeCategory
}
}
state.copy(
isLoading = false,
values = values,
schema = schema,
@@ -89,15 +172,11 @@ class ConfigViewModel :
schema?.fields?.keys ?: emptySet(),
),
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
activeCategory = validCategory,
errorMessage = null,
)
}
} else {
} else if (!hasCachedData) {
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
@@ -105,8 +184,11 @@ class ConfigViewModel :
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
}
}
}
}
@@ -198,6 +280,7 @@ class ConfigViewModel :
fun saveConfig() {
if (pendingChanges.isEmpty()) return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
val changeset = buildChangeset(pendingChanges)
@@ -209,6 +292,8 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
pendingChanges.clear()
@@ -216,6 +301,8 @@ class ConfigViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
isSaving = false,
@@ -242,6 +329,7 @@ class ConfigViewModel :
fun saveYamlConfig() {
val yamlText = _uiState.value.yamlText ?: return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(yamlIsSaving = true) }
viewModelScope.launch {
val result =
@@ -252,12 +340,16 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
yamlIsSaving = false,
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob
@@ -82,10 +83,12 @@ fun CronJobsScreen(
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var selectedJob by remember { mutableStateOf<CronJob?>(null) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadCronJobs()
}
@@ -95,7 +98,7 @@ fun CronJobsScreen(
title = { Text(stringResource(R.string.screen_cron)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() },
onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon(
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob
@@ -16,6 +19,7 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -95,6 +99,19 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
jobs = emptyList(),
errorMessage = null,
deleteTarget = null,
editorState = CronJobEditorState(),
)
}
}
init {
// Issue #784: gateway broadcasts cron.changed — refresh the list
@@ -102,14 +119,24 @@ class CronJobsViewModel :
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
onSuccess = { data ->
val jobs = data.orEmpty()
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null) jobsCache.put(requestScope.inMemoryKey("default"), jobs)
_uiState.update { it.copy(jobs = jobs) }
},
)
}
fun loadCronJobs() {
fun loadCronJobs(forceRefresh: Boolean = false) {
loadJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = jobsCache,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -128,6 +155,7 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -141,14 +169,24 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -162,8 +200,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -188,6 +235,7 @@ class CronJobsViewModel :
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
@@ -196,8 +244,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -447,7 +504,7 @@ class CronJobsViewModel :
when (result) {
is NetworkResult.Success -> {
closeEditor()
loadCronJobs()
loadCronJobs(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -79,6 +81,7 @@ fun FilesScreen(
viewModel: FilesViewModel = viewModel { FilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val uploadLauncher =
@@ -104,6 +107,13 @@ fun FilesScreen(
onDispose { viewModel.clearTransientState() }
}
// Scope-aware load: a new server/profile context must never keep the old
// server's directory path or entries on screen (PR #1192 cache scoping).
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.load()
}
val hasEntries = state.entries.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
import android.util.Base64
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
import com.m57.hermescontrol.data.model.ManagedFileDelete
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -76,8 +79,52 @@ class FilesViewModel :
private val _uiState = MutableStateFlow(FilesUiState())
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
fun load(path: String? = null) {
safeLaunchLoad(
private val filesCache = SwrCache<String, ManagedFilesListResponse>()
/**
* Reset state owned by the previous server/profile context (issue: PR #1192 cache scoping).
* The current directory path belongs to the old server and must not leak into the new one.
*/
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
currentPath = "",
parentPath = null,
crumbs = emptyList(),
entries = emptyList(),
isLoading = false,
errorMessage = null,
busyPaths = emptySet(),
isCreatingDir = false,
newDirName = "",
isUploading = false,
deleteTarget = null,
openingPath = null,
)
}
}
fun load(
path: String? = null,
forceRefresh: Boolean = false,
) {
val cacheKey = path ?: ""
safeLaunchSwrLoad(
cache = filesCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { state ->
state.copy(
isLoading = false,
currentPath = data.path,
parentPath = data.parent,
crumbs = buildCrumbs(data.path),
entries = data.entries,
errorMessage = null,
)
}
},
apiCall = {
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
},
@@ -104,7 +151,7 @@ class FilesViewModel :
)
}
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
fun refresh() = load(_uiState.value.currentPath.ifBlank { null }, forceRefresh = true)
fun navigateTo(entry: ManagedFileEntry) {
if (!entry.isDirectory) return
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -51,9 +52,11 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadStatus()
}
@@ -63,7 +66,7 @@ fun GatewayScreen(
title = { Text(stringResource(R.string.screen_gateway)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadStatus() },
onRefresh = { viewModel.loadStatus(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.status == null -> {
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,8 +30,19 @@ class GatewayViewModel :
private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() {
safeLaunchLoad(
private val statusCache = SwrCache<String, StatusResponse>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, status = null, errorMessage = null) }
}
fun loadStatus(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = statusCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { it.copy(isLoading = false, status = data, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -73,7 +86,7 @@ class GatewayViewModel :
toastMessage = "Gateway ${actionName}ed successfully",
)
}
loadStatus()
loadStatus(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject
@@ -32,7 +33,10 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -168,34 +172,71 @@ class KanbanViewModel(
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val hasExistingContent = _uiState.value.columns.isNotEmpty() || _uiState.value.tasks.isNotEmpty()
if (!hasExistingContent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch {
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
coroutineScope {
val prefetchedBoardDeferred =
if (previouslySelectedId != null) {
async(Dispatchers.IO) {
repository.getBoard(
board = previouslySelectedId,
includeArchived = _uiState.value.includeArchived,
)
}
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
null
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
_uiState.update { it.copy(boards = boards) }
if (targetBoard != null) {
if (prefetchedBoardDeferred != null && targetBoard.id == previouslySelectedId) {
preferences.setSelectedBoard(endpoint, targetBoard.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = targetBoard) }
val prefetchedResult = prefetchedBoardDeferred.await()
applyBoardResult(targetBoard, prefetchedResult, gen)
connectEvents(targetBoard)
} else {
selectBoard(targetBoard)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
selectedBoard = null,
columns = emptyList(),
tasks = emptyList(),
)
}
}
}
is NetworkResult.Failure -> {
if (!hasExistingContent) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
}
}
}
@@ -906,15 +947,11 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
private fun applyBoardResult(
board: KanbanBoard,
gen: Int = currentLoadGen,
result: NetworkResult<KanbanBoardResponse>,
gen: Int,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
@@ -928,6 +965,7 @@ class KanbanViewModel(
isLoading = false,
columns = body.columns,
tasks = allTasks,
errorMessage = null,
)
}
}
@@ -948,6 +986,18 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
applyBoardResult(board, result, gen)
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
@@ -114,6 +115,7 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
@@ -150,7 +152,8 @@ fun KeysScreen(
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadKeys()
}
@@ -211,7 +214,7 @@ fun KeysScreen(
title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() },
onRefresh = { viewModel.loadKeys(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openAddDialog() }) {
Icon(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,6 +57,19 @@ class KeysViewModel :
private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
private val keysCache = SwrCache<String, Map<String, EnvVarConfig>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
categories = emptyList(),
revealedValues = emptyMap(),
errorMessage = null,
)
}
}
private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> =
@@ -89,8 +104,21 @@ class KeysViewModel :
}
}
fun loadKeys() {
safeLaunchLoad(
fun loadKeys(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = keysCache,
forceRefresh = forceRefresh,
onCacheHit = { envVars ->
val currentCategories = _uiState.value.categories
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
_uiState.update {
it.copy(
isLoading = false,
categories = buildCategoryList(envVars, expandedCategories.keys),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -178,7 +206,7 @@ class KeysViewModel :
toastMessage = "Key added successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -223,7 +251,7 @@ class KeysViewModel :
toastMessage = "Key deleted successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -281,7 +309,7 @@ class KeysViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -70,6 +71,7 @@ fun LogsScreen(
viewModel: LogsViewModel = viewModel { LogsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val listState = rememberLazyListState()
@@ -115,7 +117,8 @@ fun LogsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadLogs()
}
@@ -146,7 +149,7 @@ fun LogsScreen(
title = { Text(stringResource(R.string.screen_logs)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadLogs() },
onRefresh = { viewModel.loadLogs(forceRefresh = true) },
actions = {
// Pause / Resume toggle
IconButton(onClick = { pauseScroll = !pauseScroll }) {
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,43 +42,51 @@ class LogsViewModel :
private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
@Volatile
private var loadInProgress = false
private var loadJob: Job? = null
private var autoRefreshJob: Job? = null
private val logsCache = SwrCache<String, LogResponse>()
fun loadLogs() {
if (loadInProgress) return
loadInProgress = true
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
}
fun loadLogs(forceRefresh: Boolean = false) {
val filters = _uiState.value.filters
safeLaunchLoad(
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val body = data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
loadInProgress = false
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
loadInProgress = false
},
)
val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
loadJob =
safeLaunchSwrLoad(
cache = logsCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
},
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
},
)
}
/** Update the server-side filters and immediately reload. */
@@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing
@@ -99,6 +100,7 @@ fun McpServersScreen(
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
@@ -127,14 +129,17 @@ fun McpServersScreen(
}
}
LaunchedEffect(Unit) { viewModel.loadServers() }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadServers()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers() },
onRefresh = { viewModel.loadServers(forceRefresh = true) },
actions = {
IconButton(
onClick = { viewModel.testAllServers() },
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
@@ -9,11 +10,13 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -69,10 +72,39 @@ class McpServersViewModel :
private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
private val mcpCache = SwrCache<String, McpServersResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
servers = emptyList(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
isTestingAll = false,
errorMessage = null,
activeOAuthFlow = null,
)
}
}
// ── Data loading ──────────────────────────────────────────
fun loadServers() {
safeLaunchLoad(
fun loadServers(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = mcpCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
servers = data.servers.orEmpty(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -247,7 +279,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -305,7 +337,9 @@ class McpServersViewModel :
toastMessage = msg,
)
}
loadServers()
if (successCount > 0) {
loadServers(forceRefresh = true)
}
}
}
@@ -371,7 +405,7 @@ class McpServersViewModel :
toastMessage = "Server '${request.name}' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -428,7 +462,7 @@ class McpServersViewModel :
toastMessage = "Env var '$key' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -453,7 +487,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -520,7 +554,7 @@ class McpServersViewModel :
toastMessage = "Catalog entry '${entry.name}' installed",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -605,7 +639,7 @@ class McpServersViewModel :
toastMessage = "OAuth authorization successful!",
)
}
loadServers()
loadServers(forceRefresh = true)
}
"error" -> {
@@ -112,7 +112,7 @@ fun MemoryScreen(
title = { Text(stringResource(R.string.screen_memory)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
onRefresh = { viewModel.load(forceRefresh = true) },
modifier = modifier,
) { paddingValues ->
when {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResponse
@@ -10,6 +13,9 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
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
@@ -39,50 +45,89 @@ class MemoryViewModel :
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
init {
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
// connection switch). An additional AuthManager.dataScopeFlow collector would
// double-fire on the same transition and issue a duplicate request, so the
// coordinator flows remain the single trigger.
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
}
fun load(silent: Boolean = false) {
private var loadJob: Job? = null
private val memoryCache = SwrCache<String, MemoryResponse>()
private fun onDataScopeChanged(newScope: DataScope?) {
loadJob?.cancel()
_uiState.update {
it.copy(
isLoading = false,
memory = null,
learningGraph = null,
resetting = null,
errorMessage = null,
)
}
// A scope switch must silently reload the new context's data. Clearing alone
// would leave the screen empty when it is not the composition performing the load.
load(silent = true)
}
fun load(
silent: Boolean = false,
forceRefresh: Boolean = false,
) {
if (_uiState.value.isLoading) return
if (!silent) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopedKey = requestScope?.inMemoryKey("default")
if (forceRefresh && scopedKey != null) memoryCache.remove(scopedKey)
val cached = if (!forceRefresh && scopedKey != null) memoryCache.get(scopedKey) else null
if (cached != null) {
_uiState.update { it.copy(isLoading = false, memory = cached, errorMessage = null) }
} else if (!silent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) }
}
loadJob =
viewModelScope.launch {
coroutineScope {
val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
val memoryResult = memoryDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope != requestScope) return@coroutineScope
when (memoryResult) {
is NetworkResult.Success -> {
if (scopedKey != null) memoryCache.put(scopedKey, memoryResult.data)
_uiState.update { it.copy(isLoading = false, memory = memoryResult.data) }
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = memoryResult.error.message,
)
}
}
}
}
val graphResult = graphDeferred.await()
if (graphResult is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = graphResult.data) }
}
}
}
loadLearningGraph()
}
}
private fun loadLearningGraph() {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = result.data) }
}
}
}
fun resetMemory(target: String) {
@@ -109,7 +154,7 @@ class MemoryViewModel :
}
}
}
load()
load(forceRefresh = true)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -98,15 +100,15 @@ fun ModelScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_models)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
isRefreshing = state.isLoading || state.catalogLoading,
onRefresh = { viewModel.loadAll(refresh = true) },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() },
@@ -114,7 +116,10 @@ fun ModelScreen(
)
}
state.providers.isEmpty() -> {
!state.isLoading &&
!state.catalogLoading &&
state.providers.isEmpty() &&
state.mainModelProvider.isEmpty() -> {
EmptyState(
title = stringResource(R.string.model_empty_title),
subtitle = stringResource(R.string.model_empty_desc),
@@ -191,24 +196,38 @@ fun ModelScreen(
}
// ── Provider List ──
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
if (state.catalogLoading && state.providers.isEmpty()) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
} else {
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
}
}
}
}
@@ -241,7 +260,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_main),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
@@ -267,7 +286,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_aux, state.auxPickerTask),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
@@ -42,6 +42,7 @@ val AUX_TASKS =
data class ModelUiState(
val isLoading: Boolean = false,
val catalogLoading: Boolean = false,
val providers: List<ModelProvider> = emptyList(),
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null,
@@ -79,7 +80,7 @@ class ModelViewModel(
}
fun loadAll(refresh: Boolean = false) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
viewModelScope.launch {
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
val activeProfileDeferred =
@@ -145,6 +146,7 @@ class ModelViewModel(
// Render Phase 1 results right away so UI controls update without waiting for model options
_uiState.update {
it.copy(
isLoading = false,
activeProfile = activeProfile ?: it.activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
mainModelProvider = mainModel.first,
@@ -160,7 +162,7 @@ class ModelViewModel(
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
catalogLoading = false,
providers = optionsResult.data.providers.orEmpty(),
)
}
@@ -169,9 +171,9 @@ class ModelViewModel(
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
catalogLoading = false,
errorMessage =
if (it.providers.isEmpty()) {
if (it.providers.isEmpty() && it.mainModelProvider.isEmpty()) {
"Failed to load model options: ${optionsResult.error.message}"
} else {
null
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PairingItem
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -50,8 +51,10 @@ fun PairingScreen(
viewModel: PairingViewModel = viewModel { PairingViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadPairing()
}
@@ -61,7 +64,7 @@ fun PairingScreen(
title = { Text(stringResource(R.string.screen_pairing)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPairing() },
onRefresh = { viewModel.loadPairing(forceRefresh = true) },
modifier = modifier,
) { paddingValues ->
val pairing = state.pairing
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,6 +40,11 @@ class PairingViewModel :
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null
private val pairingCache = SwrCache<String, PairingResponse>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, pairing = null, errorMessage = null, actionKey = null) }
}
init {
// Issue #784: gateway broadcasts pairing.changed — refresh silently
@@ -44,14 +52,23 @@ class PairingViewModel :
refreshOnChange(
eventType = ChangeEvents.PAIRING,
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onSuccess = { data -> _uiState.update { it.copy(pairing = data) } },
onSuccess = { data ->
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null) pairingCache.put(requestScope.inMemoryKey("default"), data)
_uiState.update { it.copy(pairing = data) }
},
)
}
fun loadPairing() {
fun loadPairing(forceRefresh: Boolean = false) {
launchJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = pairingCache,
forceRefresh = forceRefresh,
currentJob = launchJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, pairing = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -127,7 +144,7 @@ class PairingViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { onSuccess(it.copy(actionKey = null)) }
loadPairing()
loadPairing(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -50,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.MemoryProviderDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -75,11 +76,17 @@ fun PluginsScreen(
viewModel: PluginsViewModel = viewModel { PluginsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadPlugins()
}
val filteredPlugins =
remember(query, state.plugins) {
state.plugins.filter { plugin ->
@@ -111,10 +118,6 @@ fun PluginsScreen(
}
}
LaunchedEffect(Unit) {
viewModel.loadPlugins()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Remove confirmation dialog
@@ -152,7 +155,7 @@ fun PluginsScreen(
isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
onRefresh = {
if (state.selectedTab == PluginsTab.INSTALLED) {
viewModel.loadPlugins()
viewModel.loadPlugins(forceRefresh = true)
} else {
viewModel.loadCatalog(isRefresh = true)
}
@@ -2,10 +2,14 @@ package com.m57.hermescontrol.ui.plugins
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -13,6 +17,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -73,8 +78,56 @@ class PluginsViewModel :
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
fun loadPlugins() {
safeLaunchLoad(
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
plugins = emptyList(),
orphanPlugins = emptyList(),
errorMessage = null,
memoryProvider = "",
memoryOptions = emptyList(),
contextEngine = "compressor",
contextOptions = emptyList(),
providerBusy = false,
rowBusy = null,
removeConfirmPlugin = null,
rescanBusy = false,
)
}
}
fun loadPlugins(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = pluginsCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
val plugins = data.plugins.orEmpty()
val providers = data.providers
_uiState.update {
it.copy(
isLoading = false,
plugins = plugins,
orphanPlugins =
data.orphanDashboardPlugins.orEmpty().map { orphan ->
PluginInfo(
name = orphan.name ?: "unknown",
description = orphan.description ?: orphan.label,
version = null,
source = null,
runtimeStatus = null,
)
},
memoryProvider = providers?.memoryProvider ?: "",
memoryOptions = providers?.memoryOptions.orEmpty(),
contextEngine = providers?.contextEngine ?: "compressor",
contextOptions = providers?.contextOptions.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getPlugins() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -85,10 +138,10 @@ class PluginsViewModel :
isLoading = false,
plugins = plugins,
orphanPlugins =
data.orphanDashboardPlugins.orEmpty().map {
data.orphanDashboardPlugins.orEmpty().map { orphan ->
PluginInfo(
name = it.name ?: "unknown",
description = it.description ?: it.label,
name = orphan.name ?: "unknown",
description = orphan.description ?: orphan.label,
version = null,
source = null,
runtimeStatus = null,
@@ -153,7 +206,7 @@ class PluginsViewModel :
toastMessage = "Plugin installed successfully",
)
}
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -194,7 +247,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -219,7 +272,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -237,6 +290,7 @@ class PluginsViewModel :
fun togglePlugin(plugin: PluginInfo) {
val originalEnabled = plugin.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistically update
_uiState.update { state ->
@@ -261,8 +315,19 @@ class PluginsViewModel :
safeApiCall { ApiClient.hermesApi.disablePlugin(plugin.name) }
}
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.plugins
if (requestScope != null) {
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = updated))
}
} else if (result is NetworkResult.Failure) {
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${result.error.message}")
val reverted = _uiState.value.plugins
if (requestScope != null) {
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = reverted))
}
}
}
}
@@ -276,7 +341,7 @@ class PluginsViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -309,7 +374,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
clearRowBusy(name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -331,7 +396,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
clearRowBusy(name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -359,7 +424,7 @@ class PluginsViewModel :
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin visibility updated") }
clearRowBusy(plugin.name)
loadPlugins()
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -469,7 +534,7 @@ class PluginsViewModel :
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
)
}
loadPlugins()
loadPlugins(forceRefresh = true)
loadCatalog(isRefresh = true)
},
onError = { errorMsg ->
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -83,6 +84,7 @@ fun ProfilesScreen(
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
@@ -106,7 +108,8 @@ fun ProfilesScreen(
var isBuildingProfile by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadProfiles()
}
@@ -133,7 +136,7 @@ fun ProfilesScreen(
if (isBuildingProfile) {
null
} else {
{ viewModel.loadProfiles() }
{ viewModel.loadProfiles(forceRefresh = true) }
},
actions = {
if (!isBuildingProfile) {
@@ -3,12 +3,15 @@ package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -112,8 +115,53 @@ class ProfilesViewModel(
}
}
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
private val profilesCache = SwrCache<String, ProfilesResponse>()
/**
* Reset state owned by the previous server/profile context (PR #1192 cache scoping).
* Profiles, their soul content, setup commands, and builder data all belong to the
* server that served them and must not be shown for a new context.
*/
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
profiles = emptyList(),
activeProfileName = null,
sharedGatewayProfiles = emptyList(),
selectedSoulContent = null,
isLoadingSoul = false,
errorMessage = null,
isAutoDescribing = false,
setupCommand = null,
isLoadingSetupCommand = false,
modelPickerPinned = emptyList(),
modelProviders = emptyList(),
isLoadingBuilderData = false,
availableSkills = emptyList(),
hubSearchResults = emptyList(),
isSearchingHub = false,
)
}
}
fun loadProfiles(forceRefresh: Boolean = false) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopedKey = requestScope?.inMemoryKey("default")
if (forceRefresh && scopedKey != null) profilesCache.remove(scopedKey)
val cached = if (!forceRefresh && scopedKey != null) profilesCache.get(scopedKey) else null
if (cached != null) {
_uiState.update {
it.copy(
isLoading = false,
profiles = cached.profiles.orEmpty(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try {
coroutineScope {
@@ -125,7 +173,11 @@ class ProfilesViewModel(
val profilesResult = profilesDeferred.await()
val activeResult = activeDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope != requestScope) return@coroutineScope
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
if (scopedKey != null) profilesCache.put(scopedKey, profilesResult.data)
_uiState.update {
it.copy(
isLoading = false,
@@ -133,6 +185,7 @@ class ProfilesViewModel(
activeProfileName = activeResult.data.active,
sharedGatewayProfiles = emptyList(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -145,7 +198,7 @@ class ProfilesViewModel(
)
}
}
} else {
} else if (cached == null || forceRefresh) {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update {
@@ -159,8 +212,11 @@ class ProfilesViewModel(
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope == requestScope && (cached == null || forceRefresh)) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
}
}
}
}
@@ -178,7 +234,7 @@ class ProfilesViewModel(
when (val result = ProfileSwitchCoordinator.switchProfile(name)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -244,7 +300,7 @@ class ProfilesViewModel(
toastMessage = "Soul updated successfully",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -276,7 +332,7 @@ class ProfilesViewModel(
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -322,7 +378,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$sourceProfileName' cloned successfully to '$newProfileName'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -359,7 +415,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$profileName' description updated",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -397,7 +453,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$oldName' renamed to '$newName'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -426,7 +482,7 @@ class ProfilesViewModel(
toastMessage = "Profile '$name' deleted",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -463,7 +519,7 @@ class ProfilesViewModel(
toastMessage = "Auto-described profile '$name'",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
} else {
// Backend returns generation failures as ok:false + reason,
// NOT an HTTP error — surface the reason to the user.
@@ -656,7 +712,7 @@ class ProfilesViewModel(
toastMessage = "Profile ${request.name} created successfully",
)
}
loadProfiles()
loadProfiles(forceRefresh = true)
onSuccess()
}
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -65,9 +66,13 @@ fun ProvidersScreen(
viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.load()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -98,7 +103,7 @@ fun ProvidersScreen(
title = { Text(stringResource(R.string.screen_providers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
onRefresh = { viewModel.load(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.providers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthProvidersResponse
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -66,9 +69,35 @@ class ProvidersViewModel :
val uiState: StateFlow<ProvidersUiState> = _uiState.asStateFlow()
private var pollJob: Job? = null
private val providersCache = SwrCache<String, OAuthProvidersResponse>()
fun load() {
safeLaunchLoad(
fun clearScopeOwnedState() {
pollJob?.cancel()
_uiState.update {
it.copy(
isLoading = false,
providers = emptyList(),
errorMessage = null,
flowPhase = OAuthFlowPhase.IDLE,
flowProvider = null,
flowErrorMessage = null,
)
}
}
fun load(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = providersCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
providers = data.providers.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -105,7 +134,7 @@ class ProvidersViewModel :
toastMessage = "Disconnected",
)
}
load()
load(forceRefresh = true)
},
onError = { error ->
_uiState.update {
@@ -301,7 +330,7 @@ class ProvidersViewModel :
private fun finishFlow(status: String) {
pollJob?.cancel()
load()
load(forceRefresh = true)
// Stay on DONE so the dialog can show a success confirmation; the Screen's
// Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would
// conflate with DONE via StateFlow and unmount the dialog before the user sees it.)
@@ -468,7 +468,7 @@ fun SessionsScreen(
title = { Text(stringResource(R.string.screen_history)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadSessions() },
onRefresh = { viewModel.loadSessions(forceRefresh = true) },
actions = {
IconButton(
onClick = { NavigationController.openNewChat() },
@@ -3,16 +3,21 @@ package com.m57.hermescontrol.ui.sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.model.SessionRenameRequest
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -140,15 +145,31 @@ class SessionsViewModel(
private var liveStatusRefreshPending = false
private var generation: Long = 0
private var rawPaginationOffset: Int = 0
private val sessionsPageCache = SwrCache<String, SessionListResponse>()
init {
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
// connection switch). An additional AuthManager.dataScopeFlow collector would
// double-fire on the same transition and reset/refetch History twice.
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
// Issue #784: gateway broadcasts sessions.changed — refresh the list
// silently (no spinner, no selection reset) instead of blind polling.
refreshOnChange(
eventType = ChangeEvents.SESSIONS,
apiCall = {
val requestGeneration = generation
val section = _uiState.value.section
val capturedSection = _uiState.value.section
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
when (
val result =
safeApiCall {
@@ -156,17 +177,31 @@ class SessionsViewModel(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
source = section.source,
excludeSources = section.excludeSources,
source = capturedSection.source,
excludeSources = capturedSection.excludeSources,
)
}
) {
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
is NetworkResult.Failure -> result
is NetworkResult.Success -> {
NetworkResult.Success(
Triple(requestGeneration, Pair(requestScope, capturedSection), result.data),
)
}
is NetworkResult.Failure -> {
result
}
}
},
onSuccess = { (requestGeneration, data) ->
if (requestGeneration == generation) {
onSuccess = { (requestGeneration, scopeAndSection, data) ->
val (requestScope, capturedSection) = scopeAndSection
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration == generation && currentScope == requestScope) {
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
val inMemoryKey = requestScope?.inMemoryKey(localKey)
val persistentKey = requestScope?.persistentKey(localKey)
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
rawPaginationOffset = data.nextOffset(0)
_uiState.update {
val newSessions = data.sessions.orEmpty()
@@ -189,6 +224,41 @@ class SessionsViewModel(
)
}
private fun onDataScopeChanged(newScope: DataScope? = AuthManager.currentDataScope()) {
generation++
searchGeneration++
trackingGeneration++
loadJob?.cancel()
pageJob?.cancel()
statsJob?.cancel()
searchJob?.cancel()
searchPageJob?.cancel()
projectsJob?.cancel()
rawPaginationOffset = 0
resolvedParentCache.clear()
_uiState.update {
it.copy(
sessions = emptyList(),
total = 0,
hasMore = false,
selectedIds = emptySet(),
isSelecting = false,
errorMessage = null,
searchResults = emptyList(),
searchError = null,
searchLoadMoreError = null,
searchHasMore = false,
searchNextOffset = null,
isSearching = false,
isLoadingMoreSearch = false,
stats = SessionStats(),
emptyCount = 0,
projects = emptyList(),
)
}
loadSessions()
}
/**
* Page size sent to the server. Matches the desktop sidebar's
* SIDEBAR_SESSIONS_PAGE_SIZE (50); the backend caps `limit` at 100.
@@ -283,9 +353,43 @@ class SessionsViewModel(
}
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
fun loadSessions() {
fun loadSessions(forceRefresh: Boolean = false) {
val requestGeneration = generation
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val section = _uiState.value.section
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
val inMemoryKey = requestScope?.inMemoryKey(localKey)
val persistentKey = requestScope?.persistentKey(localKey)
if (forceRefresh) {
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
}
val cached =
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
sessionsPageCache.get(inMemoryKey)
?: SessionListCacheStore.get(persistentKey)?.also { sessionsPageCache.put(inMemoryKey, it) }
} else {
null
}
if (cached != null) {
val sessionsList = cached.sessions.orEmpty()
val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = minOf(cached.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE,
backendTotal = cached.total,
accumulatedCount = sessionsList.size,
)
_uiState.update {
it.copy(
isLoading = false,
sessions = sessionsList,
total = paging.total,
hasMore = paging.hasMore,
errorMessage = null,
)
}
}
pageJob?.cancel()
loadEmptyCount()
if (trackingJob?.isActive == true) {
@@ -306,9 +410,16 @@ class SessionsViewModel(
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onStart = {
if (cached == null) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
},
onSuccess = { data ->
if (requestGeneration != generation) return@safeLaunchLoad
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty()
val paging =
@@ -331,13 +442,15 @@ class SessionsViewModel(
stitchMissingParents(requestGeneration)
},
onError = { errorMsg ->
if (requestGeneration != generation) return@safeLaunchLoad
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
if (cached == null || forceRefresh) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
}
}
},
)
@@ -361,6 +474,7 @@ class SessionsViewModel(
val state = _uiState.value
if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return
val requestGeneration = generation
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val offset = rawPaginationOffset
_uiState.update { it.copy(isLoadingMore = true) }
@@ -376,7 +490,8 @@ class SessionsViewModel(
excludeSources = state.section.excludeSources,
)
}
if (requestGeneration != generation) return@launch
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration != generation || currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
val data = result.data
@@ -450,10 +565,11 @@ class SessionsViewModel(
if (_uiState.value.sessions.isEmpty()) loadSessions()
return
}
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
searchJob =
viewModelScope.launch {
delay(SEARCH_DEBOUNCE_MS)
requestSearchPage(query, section, requestGeneration, 0)
requestSearchPage(query, section, requestGeneration, requestScope, 0)
}
}
@@ -462,10 +578,11 @@ class SessionsViewModel(
val offset = state.searchNextOffset ?: return
if (!state.isSearchMode || state.isSearching || state.isLoadingMoreSearch || !state.searchHasMore) return
val requestGeneration = searchGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(isLoadingMoreSearch = true, searchLoadMoreError = null) }
searchPageJob =
viewModelScope.launch {
requestSearchPage(state.searchQuery, state.section, requestGeneration, offset)
requestSearchPage(state.searchQuery, state.section, requestGeneration, requestScope, offset)
}
}
@@ -473,6 +590,7 @@ class SessionsViewModel(
query: String,
section: HistorySection,
requestGeneration: Long,
requestScope: DataScope?,
offset: Int,
) {
val result =
@@ -486,7 +604,12 @@ class SessionsViewModel(
offset = offset,
)
}
if (requestGeneration != searchGeneration || _uiState.value.section != section) return
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration != searchGeneration || _uiState.value.section != section ||
currentScope != requestScope
) {
return
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { state ->
@@ -906,11 +1029,15 @@ class SessionsViewModel(
/** Refresh the empty-session count (REST; auxiliary — failures are silent). */
fun loadEmptyCount() {
val requestGeneration = generation
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
viewModelScope.launch {
val result =
runCatching {
safeApiCall { ApiClient.hermesApi.getEmptySessionCount() }
}.getOrNull()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestGeneration != generation || currentScope != requestScope) return@launch
val count = (result as? NetworkResult.Success)?.data?.count
if (count != null) {
_uiState.update { it.copy(emptyCount = count) }
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -365,6 +366,7 @@ class SettingsViewModel(
AuthManager.setSessionCookie(null)
AuthManager.setWsAuthParam("token")
AuthManager.clearLastOpenedSessionId()
SessionListCacheStore.clear()
HermesWsClient.disconnect(clearPendingMessages = true)
// Don't rebuild ApiClient here — let the navigation complete first
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
@@ -43,11 +44,13 @@ fun SkillsScreen(
viewModel: SkillsViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) }
var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadSkills()
}
@@ -58,7 +61,7 @@ fun SkillsScreen(
isRefreshing = state.isLoading,
onRefresh = {
if (state.viewMode == SkillsViewMode.INSTALLED) {
viewModel.loadSkills()
viewModel.loadSkills(forceRefresh = true)
} else {
viewModel.loadHubSources()
}
@@ -4,6 +4,9 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
@@ -17,6 +20,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -93,11 +97,35 @@ class SkillsViewModel(
private var loadJob: Job? = null
private var searchJob: Job? = null
private val skillsCache = SwrCache<String, List<Skill>>()
fun loadSkills() {
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
skills = emptyList(),
errorMessage = null,
editingSkillName = null,
skillContent = null,
isLoadingContent = false,
isSavingContent = false,
saveContentSuccess = false,
previewSkillName = null,
previewSkillContent = null,
isLoadingPreview = false,
)
}
}
fun loadSkills(forceRefresh: Boolean = false) {
loadJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = skillsCache,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, skills = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -328,7 +356,7 @@ class SkillsViewModel(
)
}
// Refresh installed skills
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -365,7 +393,7 @@ class SkillsViewModel(
toastMessage = "Uninstalled: $name",
)
}
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -396,7 +424,9 @@ class SkillsViewModel(
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
@@ -411,12 +441,23 @@ class SkillsViewModel(
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.skills
if (requestScope != null) {
skillsCache.put(requestScope.scopedKey("default"), updated)
}
} else if (result is NetworkResult.Failure) {
revertSkillToggle(
skill.name,
originalEnabled,
"Failed to toggle skill: ${result.error.message}",
)
val reverted = _uiState.value.skills
if (requestScope != null) {
skillsCache.put(requestScope.scopedKey("default"), reverted)
}
}
}
}
@@ -525,7 +566,7 @@ class SkillsViewModel(
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Skills updated") }
loadSkills()
loadSkills(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -154,48 +154,86 @@ class SystemViewModel(
val statsResult = statsDeferred.await()
val statusResult = statusDeferred.await()
val portalResult = portalDeferred.await()
val curatorResult = curatorDeferred.await()
val credResult = credDeferred.await()
val checkpointsResult = checkpointsDeferred.await()
val hooksResult = hooksDeferred.await()
val updateResult = updateDeferred.await()
val doctorResult = doctorDeferred.await()
// Phase 1: Un-gate UI immediately with fast host stats & gateway status (~100-200ms)
_uiState.update { state ->
state.copy(
isLoading = false,
stats = (statsResult as? NetworkResult.Success)?.data,
status = (statusResult as? NetworkResult.Success)?.data,
portal = (portalResult as? NetworkResult.Success)?.data,
curator = (curatorResult as? NetworkResult.Success)?.data,
credentials =
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
hooks = (hooksResult as? NetworkResult.Success)?.data,
updateInfo = (updateResult as? NetworkResult.Success)?.data,
doctorReport = (doctorResult as? NetworkResult.Success)?.data,
stats = (statsResult as? NetworkResult.Success)?.data ?: state.stats,
status = (statusResult as? NetworkResult.Success)?.data ?: state.status,
errorMessage = null,
)
}
// Log failures in debug builds
if (BuildConfig.DEBUG) {
listOf(
"stats" to statsResult,
"status" to statusResult,
"portal" to portalResult,
"curator" to curatorResult,
"credentials" to credResult,
"checkpoints" to checkpointsResult,
"hooks" to hooksResult,
"update" to updateResult,
"doctor" to doctorResult,
).forEach { (name, result) ->
if (result is NetworkResult.Failure) {
Log.w(TAG, "$name endpoint: ${result.error.message}")
}
if (statsResult is NetworkResult.Failure) {
Log.w(TAG, "stats endpoint: ${statsResult.error.message}")
}
if (statusResult is NetworkResult.Failure) {
Log.w(TAG, "status endpoint: ${statusResult.error.message}")
}
}
// Phase 2: Progressively hydrate remaining sections as each endpoint resolves
launch {
val portalResult = portalDeferred.await()
if (portalResult is NetworkResult.Success) {
_uiState.update { it.copy(portal = portalResult.data) }
} else if (BuildConfig.DEBUG && portalResult is NetworkResult.Failure) {
Log.w(TAG, "portal endpoint: ${portalResult.error.message}")
}
}
launch {
val curatorResult = curatorDeferred.await()
if (curatorResult is NetworkResult.Success) {
_uiState.update { it.copy(curator = curatorResult.data) }
} else if (BuildConfig.DEBUG && curatorResult is NetworkResult.Failure) {
Log.w(TAG, "curator endpoint: ${curatorResult.error.message}")
}
}
launch {
val credResult = credDeferred.await()
if (credResult is NetworkResult.Success) {
_uiState.update { it.copy(credentials = credResult.data.providers.orEmpty()) }
} else if (BuildConfig.DEBUG && credResult is NetworkResult.Failure) {
Log.w(TAG, "credentials endpoint: ${credResult.error.message}")
}
}
launch {
val checkpointsResult = checkpointsDeferred.await()
if (checkpointsResult is NetworkResult.Success) {
_uiState.update { it.copy(checkpoints = checkpointsResult.data) }
} else if (BuildConfig.DEBUG && checkpointsResult is NetworkResult.Failure) {
Log.w(TAG, "checkpoints endpoint: ${checkpointsResult.error.message}")
}
}
launch {
val hooksResult = hooksDeferred.await()
if (hooksResult is NetworkResult.Success) {
_uiState.update { it.copy(hooks = hooksResult.data) }
} else if (BuildConfig.DEBUG && hooksResult is NetworkResult.Failure) {
Log.w(TAG, "hooks endpoint: ${hooksResult.error.message}")
}
}
launch {
val updateResult = updateDeferred.await()
if (updateResult is NetworkResult.Success) {
_uiState.update { it.copy(updateInfo = updateResult.data) }
} else if (BuildConfig.DEBUG && updateResult is NetworkResult.Failure) {
Log.w(TAG, "update endpoint: ${updateResult.error.message}")
}
}
launch {
val doctorResult = doctorDeferred.await()
if (doctorResult is NetworkResult.Success) {
_uiState.update { it.copy(doctorReport = doctorResult.data) }
} else if (BuildConfig.DEBUG && doctorResult is NetworkResult.Failure) {
Log.w(TAG, "doctor endpoint: ${doctorResult.error.message}")
}
}
}
@@ -42,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ToolsetDetailKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -60,9 +61,15 @@ fun ToolsetsScreen(
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadToolsets()
}
val filteredToolsets =
remember(query, state.toolsets) {
state.toolsets.filter { toolset ->
@@ -72,17 +79,13 @@ fun ToolsetsScreen(
}
}
LaunchedEffect(Unit) {
viewModel.loadToolsets()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_toolsets)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadToolsets() },
onRefresh = { viewModel.loadToolsets(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.toolsets.isEmpty() -> {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.toolsets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -9,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,8 +34,19 @@ class ToolsetsViewModel :
private val _uiState = MutableStateFlow(ToolsetsUiState())
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
fun loadToolsets() {
safeLaunchLoad(
private val toolsetsCache = SwrCache<String, List<Toolset>>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, toolsets = emptyList(), errorMessage = null) }
}
fun loadToolsets(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = toolsetsCache,
forceRefresh = forceRefresh,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, toolsets = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getToolsets() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -51,6 +66,7 @@ class ToolsetsViewModel :
fun toggleToolset(toolset: Toolset) {
val originalEnabled = toolset.enabled
val targetEnabled = !originalEnabled
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
// Optimistic UI update
_uiState.update { state ->
@@ -67,8 +83,19 @@ class ToolsetsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled)) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
val updated = _uiState.value.toolsets
if (requestScope != null) {
toolsetsCache.put(requestScope.scopedKey("default"), updated)
}
} else if (result is NetworkResult.Failure) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${result.error.message}")
val reverted = _uiState.value.toolsets
if (requestScope != null) {
toolsetsCache.put(requestScope.scopedKey("default"), reverted)
}
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
@@ -71,6 +72,7 @@ fun WebhooksScreen(
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<WebhookSubscription?>(null) }
@@ -84,7 +86,8 @@ fun WebhooksScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadWebhooks()
}
@@ -232,7 +235,7 @@ fun WebhooksScreen(
title = { Text(stringResource(R.string.screen_webhooks)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadWebhooks() },
onRefresh = { viewModel.loadWebhooks(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.showCreateDialog() }) {
Icon(
@@ -2,15 +2,18 @@ package com.m57.hermescontrol.ui.webhooks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
import com.m57.hermescontrol.data.model.WebhooksResponse
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -48,8 +51,37 @@ class WebhooksViewModel :
private val _uiState = MutableStateFlow(WebhooksUiState())
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
fun loadWebhooks() {
safeLaunchLoad(
private val webhooksCache = SwrCache<String, WebhooksResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
enabled = false,
baseUrl = null,
subscriptions = emptyList(),
errorMessage = null,
deleteTarget = null,
togglingName = null,
)
}
}
fun loadWebhooks(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = webhooksCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
enabled = data.enabled,
baseUrl = data.base_url,
subscriptions = data.subscriptions.orEmpty(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getWebhooks() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -90,7 +122,7 @@ class WebhooksViewModel :
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -185,7 +217,7 @@ class WebhooksViewModel :
toastMessage = "Subscription \"$name\" created",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -226,7 +258,7 @@ class WebhooksViewModel :
toastMessage = "Subscription \"$name\" ${if (enabled) "enabled" else "disabled"}",
)
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -263,7 +295,7 @@ class WebhooksViewModel :
_uiState.update {
it.copy(toastMessage = "Subscription \"${target.name}\" deleted")
}
loadWebhooks()
loadWebhooks(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -129,6 +129,20 @@ class E2eIntegrationTest {
@After
fun tearDown() {
// Restore the GLOBAL Dispatchers object BEFORE resetMain.
//
// setUp() stubs `Dispatchers.Main` to return the TestMainDispatcher captured
// at that moment. unmockkAll() alone -- and it ran AFTER resetMain -- left
// that stub live, so Dispatchers.Main kept handing back a TestMainDispatcher
// whose delegate had already been reset, and it THROWS on dispatch. Any
// later class in this JVM that resumes a continuation onto Main then died
// with DispatchException. That is precisely what broke
// ProfileSwitchCoordinatorTest (its _switched.tryEmit resumes collectors on
// Main) with 2x DispatchException + 2x "test body did not run to completion".
//
// Verified by repro: E2eIntegrationTest + ProfileSwitchCoordinatorTest
// together = 4 failures; ProfileSwitchCoordinatorTest alone = 5/5 pass.
unmockkStatic(Dispatchers::class)
Dispatchers.resetMain()
unmockkAll()
NavigationController.backStack = null
@@ -0,0 +1,371 @@
package com.m57.hermescontrol.data.local
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Behavioural proof that PR #1192's caches are partitioned by [DataScope].
*
* These tests exercise the cache invariants end-to-end through [safeLaunchSwrLoad]
* rather than only asserting key derivation, so a regression that keeps keys
* correct but reuses the wrong entry still fails here.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CacheScopeIsolationTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private class TestViewModel : ViewModel()
/**
* Every ViewModel created by a test. `viewModelScope` jobs are never cancelled
* automatically, so a still-pending dispatch would hit `Dispatchers.Main` *after*
* `resetMain()` and surface as an `UncaughtExceptionsBeforeTest` in a later test.
* Cancel them before tearing the main dispatcher down.
*/
private val createdViewModels = mutableListOf<TestViewModel>()
private fun newViewModel(): TestViewModel = TestViewModel().also { createdViewModels += it }
/** Connection A — server 192.168.1.57, Hermes profile "main". */
private val scopeA = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L)
/** Connection B — a different Hermes *server* entirely. */
private val scopeB = DataScope("conn-b", "http://10.0.0.9:9119", "main", 0L)
/** Same server as [scopeA], different server-side Hermes profile. */
private val scopeAOtherProfile = DataScope("conn-a", "http://192.168.1.57:9119", "work", 0L)
/** Same connection+profile as [scopeA], but a new authenticated session (logout/login). */
private val scopeANewSession = scopeA.copy(inMemoryAuthGeneration = 1L)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
createdViewModels.forEach { it.viewModelScope.cancel() }
createdViewModels.clear()
testDispatcher.scheduler.advanceUntilIdle()
Dispatchers.resetMain()
}
// ── Cross-context isolation ──────────────────────────────────────────────
@Test
fun testCacheWrittenInOneScopeIsInvisibleInAnother() {
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("profiles"), "server-A-profiles")
assertNull("connection B must not see connection A's cache", cache.get(scopeB.inMemoryKey("profiles")))
assertNull(
"another Hermes profile on the same server must not see A's cache",
cache.get(scopeAOtherProfile.inMemoryKey("profiles")),
)
assertEquals("server-A-profiles", cache.get(scopeA.inMemoryKey("profiles")))
}
@Test
fun testScopeSwitchDoesNotRenderPreviousContextData() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "stale-server-A-data")
// Screen is now rendering scope B and loads with scope B's request scope.
var renderedFromCache: String? = null
var renderedFromNetwork: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeB,
scopeIsCurrent = { true },
onCacheHit = { renderedFromCache = it },
apiCall = { NetworkResult.Success("server-B-data") },
onStart = {},
onSuccess = { renderedFromNetwork = it },
onError = {},
)
assertNull("scope B must never be served scope A's cached payload", renderedFromCache)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("server-B-data", renderedFromNetwork)
assertEquals("server-B-data", cache.get(scopeB.inMemoryKey("default")))
assertEquals(
"server A's entry is untouched",
"stale-server-A-data",
cache.get(scopeA.inMemoryKey("default")),
)
}
@Test
fun testLogoutReloginCannotReuseAnotherSessionsInMemoryCache() {
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("sessions"), "session-1-confidential-titles")
assertNull(
"a new authenticated session must not reuse the previous session's in-memory data",
cache.get(scopeANewSession.inMemoryKey("sessions")),
)
}
@Test
fun testPersistentKeySurvivesProcessRestartButNotServerChange() {
// Same physical server + profile, fresh process (generation resets to 0).
val afterRestart = scopeA.copy(inMemoryAuthGeneration = 0L)
assertEquals(
"disk cache must survive a normal process restart for cold-start speed",
scopeA.persistentKey("sessions"),
afterRestart.persistentKey("sessions"),
)
assertNotEquals(
"disk cache must never bridge two different servers",
scopeA.persistentKey("sessions"),
scopeB.persistentKey("sessions"),
)
}
// ── Same-context SWR stays instant ───────────────────────────────────────
@Test
fun testSameScopeRevisitRendersCacheImmediately() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "cached-for-A")
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("revalidated-for-A") },
onStart = {},
onSuccess = {},
onError = {},
)
assertEquals("revisiting the same context must paint from cache", "cached-for-A", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("revalidated-for-A", cache.get(scopeA.inMemoryKey("default")))
}
// ── Manual refresh ───────────────────────────────────────────────────────
@Test
fun testForceRefreshPurgesOnlyItsOwnScope() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "A-data")
cache.put(scopeB.inMemoryKey("default"), "B-data")
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scopeB,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("B-fresh") },
onStart = {},
onSuccess = {},
onError = {},
)
assertNull("force refresh must not replay the cached payload", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("B-fresh", cache.get(scopeB.inMemoryKey("default")))
assertEquals("other scopes' entries must survive", "A-data", cache.get(scopeA.inMemoryKey("default")))
}
// ── Mutation reconciliation ──────────────────────────────────────────────
@Test
fun testSuccessfulMutationCannotReplayPreMutationCache() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
val key = scopeA.inMemoryKey("webhooks")
// Pre-mutation server state.
cache.put(key, "webhook-disabled")
var cacheHit: String? = null
var success: String? = null
// Post-mutation reload: must bypass the now-stale cache entry.
vm.safeLaunchSwrLoad(
cache = cache,
cacheKey = "webhooks",
forceRefresh = true,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("webhook-enabled") },
onStart = {},
onSuccess = { success = it },
onError = {},
)
assertNull("pre-mutation state must not be replayed", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("webhook-enabled", success)
assertEquals("webhook-enabled", cache.get(key))
}
@Test
fun testMutationReloadFailureAfterForcedRefreshSurfacesError() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "pre-mutation")
var error: String? = null
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "boom")) },
onStart = {},
onSuccess = {},
onError = { error = it },
)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(cacheHit)
assertNotNull(
"a failed post-mutation reload must not silently show pre-mutation state",
error,
)
}
// ── Stale in-flight results ──────────────────────────────────────────────
@Test
fun testLateResponseFromPreviousScopeCannotOverwriteNewState() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
var cacheHit: String? = null
var success: String? = null
var error: String? = null
// Request dispatched while scope A was active; the scope flips to B before it returns.
var scopeIsCurrent = true
val job =
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeA,
scopeIsCurrent = { scopeIsCurrent },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("server-A-data") },
onStart = {},
onSuccess = { success = it },
onError = { error = it },
)
scopeIsCurrent = false
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(job.isCompleted)
assertNull("stale scope A response must not be rendered", success)
assertNull(
"stale scope A response must not enter scope A's cache",
cache.get(scopeA.inMemoryKey("default")),
)
assertNull(cacheHit)
assertNull(error)
}
// ── Config editor isolation ──────────────────────────────────────────────
@Test
fun testConfigSchemaAndDefaultsNeverCrossScopeBoundaries() {
val schemaCache = SwrCache<String, String>(maxCapacity = 10)
val defaultsCache = SwrCache<String, String>(maxCapacity = 10)
val keyA = scopeA.inMemoryKey("schema")
val keyB = scopeB.inMemoryKey("schema")
schemaCache.put(keyA, "schema-A")
defaultsCache.put(keyA, "defaults-A")
assertNull("new context must not inherit another server's schema", schemaCache.get(keyB))
assertNull("new context must not inherit another server's defaults", defaultsCache.get(keyB))
schemaCache.put(keyB, "schema-B")
defaultsCache.put(keyB, "defaults-B")
assertEquals("schema-A", schemaCache.get(keyA))
assertEquals("schema-B", schemaCache.get(keyB))
assertEquals("defaults-A", defaultsCache.get(keyA))
assertEquals("defaults-B", defaultsCache.get(keyB))
}
@Test
fun testConfigForceRefreshDoesNotEvictOtherScopes() {
val schemaCache = SwrCache<String, String>(maxCapacity = 10)
val keyA = scopeA.inMemoryKey("schema")
val keyB = scopeB.inMemoryKey("schema")
schemaCache.put(keyA, "schema-A")
schemaCache.put(keyB, "schema-B")
// Manual refresh while scoped to B.
schemaCache.remove(keyB)
assertNull(schemaCache.get(keyB))
assertEquals(
"editing config on one server must not wipe another server's cache",
"schema-A",
schemaCache.get(keyA),
)
}
// ── History pagination keys ──────────────────────────────────────────────
@Test
fun testHistoryPaginationKeysAreScopedPerSectionAndScope() {
val cache = SwrCache<String, String>()
val sectionKey = "recent:cli:false"
cache.put(scopeA.inMemoryKey(sectionKey), "page-1-for-A")
assertNull(cache.get(scopeB.inMemoryKey(sectionKey)))
assertNull(cache.get(scopeAOtherProfile.inMemoryKey(sectionKey)))
assertEquals("page-1-for-A", cache.get(scopeA.inMemoryKey(sectionKey)))
}
}
@@ -0,0 +1,79 @@
package com.m57.hermescontrol.data.local
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DataScopeTest {
@Test
fun testSameScopeProducesSameKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
assertEquals(scope1, scope2)
assertEquals(scope1.inMemoryKey("sessions"), scope2.inMemoryKey("sessions"))
assertEquals(scope1.persistentKey("sessions"), scope2.persistentKey("sessions"))
}
@Test
fun testDifferentConnectionProducesDifferentKeys() {
val scope1 = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("conn-b", "http://192.168.1.57:9119", "main", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testChangedBaseUrlProducesDifferentKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://10.0.0.2:9119", "main", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testDifferentActiveProfileProducesDifferentKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "profile-1", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "profile-2", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testAuthGenerationInvalidatesInMemoryKeyOnly() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 1L)
assertNotEquals(scope1.inMemoryKey("default"), scope2.inMemoryKey("default"))
assertEquals(scope1.persistentKey("default"), scope2.persistentKey("default"))
}
@Test
fun testLengthPrefixPreventsDelimiterInjection() {
val scopeA = DataScope("c:1", "http://url", "p", 0L)
val scopeB = DataScope("c", "1:http://url", "p", 0L)
assertNotEquals(scopeA.persistentKey("k"), scopeB.persistentKey("k"))
assertNotEquals(scopeA.inMemoryKey("k"), scopeB.inMemoryKey("k"))
}
@Test
fun testNoSecretsInKeys() {
val scope = DataScope("profile-id", "http://host:9119", "hermes-profile", 0L)
val inMemory = scope.inMemoryKey("local")
val persistent = scope.persistentKey("local")
assertTrue(!inMemory.contains("token", ignoreCase = true))
assertTrue(!inMemory.contains("password", ignoreCase = true))
assertTrue(!inMemory.contains("secret", ignoreCase = true))
assertTrue(!persistent.contains("token", ignoreCase = true))
assertTrue(!persistent.contains("password", ignoreCase = true))
assertTrue(!persistent.contains("secret", ignoreCase = true))
}
}
@@ -0,0 +1,130 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class SessionListCacheStoreTest {
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private lateinit var mockContext: Context
private val prefStorage = mutableMapOf<String, String>()
@Before
fun setUp() {
prefStorage.clear()
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
every { mockContext.applicationContext } returns mockContext
every { mockContext.getSharedPreferences(any(), any()) } returns mockPrefs
every { mockPrefs.edit() } returns mockEditor
every { mockEditor.putString(any(), any()) } answers {
prefStorage[firstArg()] = secondArg()
mockEditor
}
every { mockPrefs.getString(any(), any()) } answers {
prefStorage[firstArg()] ?: secondArg()
}
every { mockEditor.remove(any()) } answers {
prefStorage.remove(firstArg())
mockEditor
}
every { mockEditor.clear() } answers {
prefStorage.clear()
mockEditor
}
every { mockEditor.apply() } returns Unit
SessionListCacheStore.resetForTesting(mockPrefs)
}
@After
fun tearDown() {
SessionListCacheStore.resetForTesting(null)
}
@Test
fun testPutAndGet() {
val sample =
SessionListResponse(
sessions =
listOf(
SessionInfo(
id = "s-123",
title = "Test Session",
),
),
total = 1,
)
SessionListCacheStore.put("active", sample)
val loaded = SessionListCacheStore.get("active")
assertNotNull(loaded)
assertEquals(1, loaded?.total)
assertEquals("s-123", loaded?.sessions?.firstOrNull()?.id)
assertEquals("Test Session", loaded?.sessions?.firstOrNull()?.title)
}
@Test
fun testCacheMiss() {
assertNull(SessionListCacheStore.get("nonexistent"))
}
@Test
fun testCorruptedJsonReturnsNull() {
prefStorage["sessions_page_corrupt"] = "{ not valid json }"
assertNull(SessionListCacheStore.get("corrupt"))
}
@Test
fun testScopePartitioningCoexistence() {
val scopeA = DataScope("conn-a", "http://server-a", "profile-a")
val scopeB = DataScope("conn-b", "http://server-b", "profile-b")
val keyA = scopeA.persistentKey("recent")
val keyB = scopeB.persistentKey("recent")
val responseA = SessionListResponse(sessions = listOf(SessionInfo(id = "a-1", title = "Session A")), total = 1)
val responseB = SessionListResponse(sessions = listOf(SessionInfo(id = "b-1", title = "Session B")), total = 1)
SessionListCacheStore.put(keyA, responseA)
SessionListCacheStore.put(keyB, responseB)
val loadedA = SessionListCacheStore.get(keyA)
val loadedB = SessionListCacheStore.get(keyB)
assertNotNull(loadedA)
assertNotNull(loadedB)
assertEquals("Session A", loadedA?.sessions?.first()?.title)
assertEquals("Session B", loadedB?.sessions?.first()?.title)
// Removing keyA does not remove keyB
SessionListCacheStore.remove(keyA)
assertNull(SessionListCacheStore.get(keyA))
assertNotNull(SessionListCacheStore.get(keyB))
}
@Test
fun testClearRemovesAll() {
val sample = SessionListResponse(sessions = emptyList(), total = 0)
SessionListCacheStore.put("k1", sample)
SessionListCacheStore.put("k2", sample)
SessionListCacheStore.clear()
assertNull(SessionListCacheStore.get("k1"))
assertNull(SessionListCacheStore.get("k2"))
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.data.local
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SwrCacheTest {
@Test
fun testCacheHitReturnsValue() {
val cache = SwrCache<String, String>()
cache.put("k1", "v1")
assertEquals("v1", cache.get("k1"))
}
@Test
fun testCacheMissReturnsNull() {
val cache = SwrCache<String, String>()
assertNull(cache.get("nonexistent"))
}
@Test
fun testTtlExpiration() {
var currentTime = 1000L
val cache =
SwrCache<String, String>(
ttlMillis = 500L,
timeProvider = { currentTime },
)
cache.put("k1", "v1")
assertEquals("v1", cache.get("k1"))
// Advance past TTL
currentTime = 1600L
assertNull(cache.get("k1"))
assertEquals(0, cache.size)
}
@Test
fun testLruEviction() {
val cache = SwrCache<String, String>(maxCapacity = 2)
cache.put("k1", "v1")
cache.put("k2", "v2")
cache.put("k3", "v3")
// k1 should have been evicted
assertNull(cache.get("k1"))
assertEquals("v2", cache.get("k2"))
assertEquals("v3", cache.get("k3"))
assertEquals(2, cache.size)
}
@Test
fun testClearAndRemove() {
val cache = SwrCache<String, String>()
cache.put("k1", "v1")
cache.put("k2", "v2")
val removed = cache.remove("k1")
assertEquals("v1", removed)
assertNull(cache.get("k1"))
assertEquals(1, cache.size)
cache.clear()
assertEquals(0, cache.size)
assertNull(cache.get("k2"))
}
@Test
fun testThreadSafetyConcurrentAccess() {
val cache = SwrCache<String, Int>(maxCapacity = 100)
val threads =
List(10) { threadIdx ->
Thread {
for (i in 0 until 500) {
cache.put("k-$threadIdx-$i", i)
cache.get("k-$threadIdx-$i")
if (i % 5 == 0) cache.remove("k-$threadIdx-$i")
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
}
}
@@ -14,11 +14,15 @@ import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
@@ -35,16 +39,29 @@ import retrofit2.Response
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileSwitchCoordinatorTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
@Before
fun setUp() {
// NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock
// bleeds into later test classes in the same JVM (it hijacks
// Dispatchers.IO for HermesWsClient's reconnect coroutines), which
// deterministically broke HermesWsClientTest.testAutoReconnect in
// full-suite runs. Real Dispatchers.IO is fine: the network layer is
// mocked, so withContext(Dispatchers.IO) hops are instant.
// setMain is REQUIRED, and it is NOT the thing that used to bleed.
//
// The leak was mockkStatic(Dispatchers::class) -- absent here, keep it that
// way. Avoiding setMain as well was the actual bug: with no setMain,
// Dispatchers.Main is the JVM's "missing" dispatcher, and switchProfile's
// _switched.tryEmit resumes collectors onto Main. Once ANY earlier class in
// the same test JVM has installed and then reset a TestMainDispatcher,
// dispatching to Main throws -- which is exactly why this class passed 5/5
// in isolation but lost 4 tests in full-suite runs (2x DispatchException
// "Dispatchers.Main threw an exception", 2x "test body did not run to
// completion"). Every other test class here already does this.
Dispatchers.setMain(testDispatcher)
// Drive the coordinator's network hops on the test dispatcher instead of a
// live IO thread pool. With real Dispatchers.IO the mocked calls were
// recorded from a different thread and the Ordering.SEQUENCE assertions
// became load-dependent -- green on an idle machine, 2 failures while the
// emulator saturated the CPU.
ProfileSwitchCoordinator.ioDispatcher = testDispatcher
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
@@ -60,6 +77,8 @@ class ProfileSwitchCoordinatorTest {
@After
fun tearDown() {
ProfileSwitchCoordinator.ioDispatcher = Dispatchers.IO
Dispatchers.resetMain()
unmockkAll()
}
@@ -72,7 +91,18 @@ class ProfileSwitchCoordinatorTest {
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.setActiveProfile(SetActiveProfileRequest("meow")) }
verify(ordering = Ordering.SEQUENCE) {
// Ordering.ORDERED, not SEQUENCE: the point of this test is the relative
// ORDER of the switch steps. SEQUENCE also demands that NO other call
// touches the mocked objects in between, which is untestable in a shared
// test JVM -- real-thread tests (HermesWsClientTest) leave background
// ticket-mint retries calling AuthManager.getBaseUrl()/getServerStore()
// for the rest of the process. Under load those 18 foreign calls landed
// inside the verification window and failed the exact-count check even
// though all 5 steps fired in the correct order (proven by the MockK
// call trace: 1) setActiveProfileId 2) rebuild ... 21) sync 22)
// disconnect 23) connect). ORDER keeps the order-pinning and ignores
// foreign traffic.
verify(ordering = Ordering.ORDERED) {
AuthManager.setActiveProfileId("meow")
HermesWsClient.disconnect()
HermesWsClient.connect()
@@ -128,7 +158,12 @@ class ProfileSwitchCoordinatorTest {
// server's cookie → 401 → dead socket), then the socket re-dials
// — so chat's wipe (via the broadcast) lands before the new
// gateway's gateway.ready auto-creates the fresh session.
coVerify(ordering = Ordering.SEQUENCE) {
//
// ORDERED, not SEQUENCE -- see the sibling test above: SEQUENCE's
// exact-count semantics fail under load in the shared test JVM when
// leaked background retries interleave foreign AuthManager/ApiClient
// calls into the window. ORDER still pins this 5-step sequence.
coVerify(ordering = Ordering.ORDERED) {
AuthManager.setSelectedProfileId("prof-2")
ApiClient.rebuild()
AuthManager.syncCookieStoreForProfile("prof-2")
@@ -1,16 +1,22 @@
package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -18,6 +24,9 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ViewModelExtTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private class TestViewModel : ViewModel()
@Before
fun setUp() {
@@ -29,46 +38,149 @@ class ViewModelExtTest {
Dispatchers.resetMain()
}
private class TestViewModel : ViewModel()
@Test
fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() =
runTest(testDispatcher) {
fun testNormalSwrCacheHitInvokesOnCacheHitImmediately() =
testScope.runTest {
val vm = TestViewModel()
var started = false
var successResult: String? = null
var completed = false
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
vm.safeLaunchAction(
onStart = { started = true },
apiCall = { NetworkResult.Success("ok") },
onSuccess = { successResult = it },
var cacheHit: String? = null
var success: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("fresh-val") },
onStart = {},
onSuccess = { success = it },
onError = {},
onComplete = { completed = true },
)
assertTrue(started)
// Cache hit happens synchronously before coroutine dispatches
assertEquals("cached-val", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("ok", successResult)
assertTrue(completed)
assertEquals("fresh-val", success)
assertEquals("fresh-val", cache.get(scopedKey))
}
@Test
fun `safeLaunchAction triggers onError and onComplete on failure`() =
runTest(testDispatcher) {
fun testForceRefreshBypassesCacheHitAndEmitsErrors() =
testScope.runTest {
val vm = TestViewModel()
var errorMessage: String? = null
var completed = false
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
vm.safeLaunchAction(
apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) },
var cacheHit: String? = null
var errorReceived: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) },
onStart = {},
onSuccess = {},
onError = { errorMessage = it },
onComplete = { completed = true },
onError = { errorReceived = it },
)
// Force refresh does NOT hit cache
assertEquals(null, cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("network error", errorMessage)
assertTrue(completed)
assertEquals("Server Error", errorReceived)
}
@Test
fun testSilentBackgroundFailurePreservesCachedContent() =
testScope.runTest {
val vm = TestViewModel()
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
var cacheHit: String? = null
var errorReceived: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = false,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) },
onStart = {},
onSuccess = {},
onError = { errorReceived = it },
)
assertEquals("cached-val", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
// Error is suppressed on silent revalidation failure
assertNull(errorReceived)
assertEquals("cached-val", cache.get(scopedKey))
}
@Test
fun testForcedRefreshSupersedesActiveJob() =
testScope.runTest {
val vm = TestViewModel()
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val deferred1 = CompletableDeferred<NetworkResult<String>>()
val deferred2 = CompletableDeferred<NetworkResult<String>>()
var req1Success = false
var req2Success = false
val job1 =
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = false,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = {},
apiCall = { deferred1.await() },
onStart = {},
onSuccess = { req1Success = true },
onError = {},
)
val job2 =
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
currentJob = job1,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = {},
apiCall = { deferred2.await() },
onStart = {},
onSuccess = { req2Success = true },
onError = {},
)
assertTrue(job1.isCancelled)
assertFalse(job2.isCancelled)
deferred2.complete(NetworkResult.Success("resp-2"))
deferred1.complete(NetworkResult.Success("resp-1"))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(req2Success)
assertFalse(req1Success)
}
}