diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 2ffcadd6..2289e292 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index ab99c0f7..eb3665e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -127,6 +127,41 @@ object AuthManager { initialValue = null, ) + private val _authGenerationFlow = MutableStateFlow(0L) + val authGenerationFlow: StateFlow = _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 = + 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(), diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt b/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt new file mode 100644 index 00000000..35a40579 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt new file mode 100644 index 00000000..fadaf9a8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt @@ -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(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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt b/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt new file mode 100644 index 00000000..bcfb59ff --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt @@ -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( + private val maxCapacity: Int = DEFAULT_MAX_CAPACITY, + private val ttlMillis: Long = DEFAULT_TTL_MILLIS, + private val timeProvider: () -> Long = { System.currentTimeMillis() }, +) { + private data class Entry( + val value: V, + val timestamp: Long, + ) + + private val lock = Any() + private val map = + object : LinkedHashMap>(maxCapacity, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 6512ec99..a19c8281 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -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 safeApiCall( } val backoff = jitteredBackoff(500L * (1 shl attempt)) delay(backoff) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { return NetworkResult.Failure( NetworkError.Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt index 7fe39ad8..69c0a063 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt @@ -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(extraBufferCapacity = 1) val switched: SharedFlow = _switched.asSharedFlow() @@ -39,7 +52,7 @@ object ProfileSwitchCoordinator { suspend fun switchProfile(name: String): NetworkResult { 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 → diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt index a085a9eb..3abc84c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt index a01a8388..fed146df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt @@ -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 = _uiState.asStateFlow() private val api get() = ApiClient.hermesApi + private val achievementsCache = SwrCache() - 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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index d261d1d2..124af95b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -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? = 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? = 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(jsonElement) + if (!resp.profiles.isNullOrEmpty()) { + profilesWithMeta = resp.profiles + } } - if (jsonElement != null) { - val resp = OkHttpProvider.json.decodeFromJsonElement(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, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 56d2473e..6a6cd8b4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -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 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 ViewModel.safeLaunchSwrLoad( + cache: SwrCache, + 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, + 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 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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 3291ef4a..213ee6a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -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() }) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index 29141d4b..4e85d684 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -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 = _uiState.asStateFlow() private val pendingChanges = mutableMapOf() + private val schemaCache = SwrCache(maxCapacity = 10) + private val defaultsCache = SwrCache>(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? = + (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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 7e408b72..ddbc7235 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -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(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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 7049162c..de760d43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 = _uiState.asStateFlow() private var loadJob: Job? = null + private val jobsCache = SwrCache>() + + 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt index 22d98316..448b6497 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt index 6b58ca84..6c25d360 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt @@ -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 = _uiState.asStateFlow() - fun load(path: String? = null) { - safeLaunchLoad( + private val filesCache = SwrCache() + + /** + * 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt index 8fdd09d0..2114b74e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt index 7f865b07..006bbe3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt @@ -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 = _uiState.asStateFlow() - fun loadStatus() { - safeLaunchLoad( + private val statusCache = SwrCache() + + 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 5c7d38c9..d11c98fd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -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, + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index dde8ab3f..d5177b20 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index 9af30639..11d11030 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -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 = _uiState.asStateFlow() + private val keysCache = SwrCache>() + + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + categories = emptyList(), + revealedValues = emptyMap(), + errorMessage = null, + ) + } + } + private fun buildCategoryList( envVars: Map, expandedCategories: Set = @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index 7fab44de..43d80dfc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -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 }) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index e50663d6..0ca598a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -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 = _uiState.asStateFlow() - @Volatile - private var loadInProgress = false - + private var loadJob: Job? = null private var autoRefreshJob: Job? = null + private val logsCache = SwrCache() - 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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index df7e9748..cc79bb43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -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() }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 3c64bacd..ec585b4d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -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 = _uiState.asStateFlow() + private val mcpCache = SwrCache() + + 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" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index a8d9d5ce..a6d30316 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt index 1ec57e4f..9590a9a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt @@ -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 = _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() + + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index b9790328..fa497374 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -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 -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index b7c82a4f..fd8ae68b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -42,6 +42,7 @@ val AUX_TASKS = data class ModelUiState( val isLoading: Boolean = false, + val catalogLoading: Boolean = false, val providers: List = 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt index 520183b8..edd8e261 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt index bee3da57..f4fb5d3c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -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 = _uiState.asStateFlow() private var launchJob: Job? = null + private val pairingCache = SwrCache() + + 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9b391987..9d2bca7b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -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(null) } var showCatalogDetail by remember { mutableStateOf(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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index d61507bb..bcbc4e6b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -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 = _uiState.asStateFlow() - fun loadPlugins() { - safeLaunchLoad( + private val pluginsCache = SwrCache() + + 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 -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 468888b7..62fb5dd9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -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(null) } var modelEditProfileName by remember { mutableStateOf(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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index b48fc8ee..2dcf7823 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -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() + + /** + * 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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt index 17b71a86..e1b77bea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt @@ -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() -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt index 98613200..4c183916 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt @@ -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 = _uiState.asStateFlow() private var pollJob: Job? = null + private val providersCache = SwrCache() - 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.) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 580f129e..3f00fcb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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() }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index dc1a849c..30c20414 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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() 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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 9f543892..e1732dba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 9e58ccb2..a339df71 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index a7dc2215..b0a7dda7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -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>() - 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 205f4f84..88b8b69e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -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}") } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index 3f938d9e..9378ed04 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -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() -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt index 03fed24f..be72c4c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt @@ -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 = _uiState.asStateFlow() - fun loadToolsets() { - safeLaunchLoad( + private val toolsetsCache = SwrCache>() + + 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) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index 6fb6ddc3..0684e48f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -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(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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt index bf39c48b..3ef4a280 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt @@ -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 = _uiState.asStateFlow() - fun loadWebhooks() { - safeLaunchLoad( + private val webhooksCache = SwrCache() + + 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 -> { diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 80fbaf41..961ae46f 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt new file mode 100644 index 00000000..222c3ce2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt @@ -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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + + 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(maxCapacity = 10) + val defaultsCache = SwrCache(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(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() + 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))) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt new file mode 100644 index 00000000..89ee1158 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt new file mode 100644 index 00000000..cf41a7dc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt @@ -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() + + @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")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt new file mode 100644 index 00000000..2e783bd2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt @@ -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() + cache.put("k1", "v1") + assertEquals("v1", cache.get("k1")) + } + + @Test + fun testCacheMissReturnsNull() { + val cache = SwrCache() + assertNull(cache.get("nonexistent")) + } + + @Test + fun testTtlExpiration() { + var currentTime = 1000L + val cache = + SwrCache( + 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(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() + 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(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() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt index 1dc9f516..af078c6c 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt @@ -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") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt index 962e6b37..6c727470 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt @@ -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() + 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() + 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() + 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() + val scope = DataScope("default", "http://server", "main", 0L) + + val deferred1 = CompletableDeferred>() + val deferred2 = CompletableDeferred>() + + 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) } }