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