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
@@ -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 →
|
||||
|
||||
Reference in new issue
Block a user