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

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

No files matched your search

@@ -127,6 +127,41 @@ object AuthManager {
initialValue = null,
)
private val _authGenerationFlow = MutableStateFlow(0L)
val authGenerationFlow: StateFlow<Long> = _authGenerationFlow.asStateFlow()
fun invalidateAuthGeneration() {
_authGenerationFlow.value = _authGenerationFlow.value + 1
}
/**
* Canonical observable data-scope identity covering connection profile, base URL,
* active server-side Hermes profile, and in-memory auth generation.
*/
val dataScopeFlow: StateFlow<DataScope?> =
combine(
selectedProfileFlow,
baseUrlFlow,
activeProfileId,
authGenerationFlow,
) { selectedProfile, baseUrl, activeProfile, authGen ->
val cleanUrl = baseUrl.trimEnd('/')
if (cleanUrl.isBlank()) {
null
} else {
DataScope(
connectionProfileId = selectedProfile ?: DEFAULT_PROFILE_ID,
baseUrl = cleanUrl,
activeProfileId = activeProfile?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = authGen,
)
}
}.stateIn(
scope = contextScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -595,6 +630,9 @@ object AuthManager {
}
fun setToken(token: String?) {
if (token == null) {
invalidateAuthGeneration()
}
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -611,6 +649,23 @@ object AuthManager {
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun currentDataScope(): DataScope? {
val rawUrl = runCatching { getBaseUrl() }.getOrNull() ?: _baseUrlFlow.value
val cleanUrl = rawUrl.trimEnd('/')
if (cleanUrl.isBlank()) return null
val connectionProfileId =
runCatching { getSelectedProfileId() }.getOrNull()
?: _selectedProfileFlow.value
?: DEFAULT_PROFILE_ID
val activeProfile = _activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
return DataScope(
connectionProfileId = connectionProfileId,
baseUrl = cleanUrl,
activeProfileId = activeProfile,
inMemoryAuthGeneration = _authGenerationFlow.value,
)
}
fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.data.local
/**
* Canonical data-scope identity representing the authenticated server and profile context.
* Used to partition caches (both in-memory and persistent) so that switching servers,
* changing server URLs, switching server-side profiles, or logging out/re-logging in
* never leaks data across boundaries.
*
* Contains no credentials, tokens, cookies, passwords, or secrets.
*/
data class DataScope(
val connectionProfileId: String,
val baseUrl: String,
val activeProfileId: String,
val inMemoryAuthGeneration: Long = 0L,
) {
/**
* Deterministic in-memory cache key.
* Uses length-prefixing for unambiguous separation of components, and includes
* [inMemoryAuthGeneration] so logging out and re-logging in automatically invalidates
* all in-memory caches.
*/
fun inMemoryKey(localKey: String = "default"): String = "g$inMemoryAuthGeneration:${persistentKey(localKey)}"
/**
* Deterministic persistent cache key for disk storage (e.g. SessionListCacheStore).
* Uses length-prefixing so no delimiter collision is possible.
* Does NOT include volatile [inMemoryAuthGeneration] so disk caches survive normal process restarts.
* Disk caches are explicitly wiped on logout.
*/
fun persistentKey(localKey: String = "default"): String =
"c${connectionProfileId.length}:$connectionProfileId:u${baseUrl.length}:$baseUrl:p${activeProfileId.length}:$activeProfileId:k${localKey.length}:$localKey"
fun scopedKey(localKey: String = "default"): String = inMemoryKey(localKey)
companion object {
val EMPTY =
DataScope(
connectionProfileId = AuthManager.DEFAULT_PROFILE_ID,
baseUrl = "",
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = 0L,
)
}
}
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
/**
* Lightweight disk cache for the initial page of sessions.
* Persists the first page JSON to SharedPreferences so the History screen
* can render instantly even after app kill / process death.
*/
object SessionListCacheStore {
private const val PREFS_NAME = "hermes_sessions_cache"
private const val KEY_PREFIX = "sessions_page_"
@Volatile
private var prefs: SharedPreferences? = null
fun init(context: Context) {
if (prefs == null) {
synchronized(this) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
}
}
fun get(key: String): SessionListResponse? {
val p = prefs ?: return null
val raw = p.getString(KEY_PREFIX + key, null) ?: return null
return try {
OkHttpProvider.json.decodeFromString<SessionListResponse>(raw)
} catch (_: Exception) {
null
}
}
fun put(
key: String,
data: SessionListResponse,
) {
val p = prefs ?: return
try {
val json = OkHttpProvider.json.encodeToString(SessionListResponse.serializer(), data)
p.edit().putString(KEY_PREFIX + key, json).apply()
} catch (_: Exception) {
// Ignore serialization issues on cache save
}
}
fun remove(key: String) {
prefs?.edit()?.remove(KEY_PREFIX + key)?.apply()
}
fun clear() {
prefs?.edit()?.clear()?.apply()
}
@VisibleForTesting
internal fun resetForTesting(testPrefs: SharedPreferences? = null) {
synchronized(this) {
prefs = testPrefs
}
}
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.data.local
/**
* Lightweight thread-safe in-memory cache for Stale-While-Revalidate screen state.
* Stores entries with optional TTL and a bounded capacity.
*
* All map operations are synchronized on a private lock so that compound
* operations (e.g. read + TTL expiry check + remove) are atomic and safe against races.
*/
class SwrCache<K : Any, V : Any>(
private val maxCapacity: Int = DEFAULT_MAX_CAPACITY,
private val ttlMillis: Long = DEFAULT_TTL_MILLIS,
private val timeProvider: () -> Long = { System.currentTimeMillis() },
) {
private data class Entry<V>(
val value: V,
val timestamp: Long,
)
private val lock = Any()
private val map =
object : LinkedHashMap<K, Entry<V>>(maxCapacity, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, Entry<V>>?): Boolean = size > maxCapacity
}
fun get(key: K): V? =
synchronized(lock) {
val entry = map[key] ?: return null
if (ttlMillis > 0 && timeProvider() - entry.timestamp > ttlMillis) {
map.remove(key)
return null
}
entry.value
}
fun put(
key: K,
value: V,
) {
synchronized(lock) {
map[key] = Entry(value, timeProvider())
}
}
fun remove(key: K): V? =
synchronized(lock) {
map.remove(key)?.value
}
fun clear() {
synchronized(lock) {
map.clear()
}
}
val size: Int
get() = synchronized(lock) { map.size }
companion object {
const val DEFAULT_MAX_CAPACITY = 50
const val DEFAULT_TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
}
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
return NetworkResult.Failure(
NetworkError.Unknown(
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -31,6 +32,18 @@ import kotlinx.coroutines.withContext
* 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
*/
object ProfileSwitchCoordinator {
/**
* Dispatcher for the blocking network hops below.
*
* Injectable so tests can drive the whole switch on a TestDispatcher. With the
* real Dispatchers.IO these paths hop to a live thread pool and the ORDER of
* the mocked calls becomes load-dependent -- ProfileSwitchCoordinatorTest's
* Ordering.SEQUENCE checks passed on an idle machine but lost 2 tests while the
* emulator saturated the CPU, and its setMain-less sibling tests failed
* outright whenever another class had left Dispatchers.Main broken.
*/
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
val switched: SharedFlow<String> = _switched.asSharedFlow()
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
suspend fun switchProfile(name: String): NetworkResult<Unit> {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
}
if (result !is NetworkResult.Success) return result
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
// run off the main thread or the dial crashes with
// NetworkOnMainThreadException and falls back to the 1s reconnect
// retry (visible in the 2026-08-06 live logcat).
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
HermesWsClient.disconnect()
HermesWsClient.connect()
}
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
AuthManager.setSelectedProfileId(profileId)
ApiClient.rebuild()
_connectionSwitched.emit(profileId.orEmpty())
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
// The WS ticket mint reads the cookie jar's ACTIVE store; the
// selection change swaps that store asynchronously, so a dial
// that races it mints with the PREVIOUS server's cookie → 401 →