perf: optimize startup, database, and ui rendering (#405)

* perf: optimize startup, database, and ui rendering

* fix(#405): address code review feedback

* test: wait for async prefs initialization to prevent coroutine leaks

* test: stub mockViewModel.streamingState in ChatScreenTest to prevent ClassCastException on Android
This commit is contained in:
M57 authored and GitHub committed 2026-06-27 01:38:44 +03:00
1 parent c0ebc8ec64
commit 8f14aca647
14 files changed
+435 -169

No files matched your search

@@ -7,9 +7,14 @@ import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
/**
* Singleton that manages encrypted storage of the Hermes dashboard token
@@ -43,7 +48,7 @@ object AuthManager {
private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30
@Volatile
private var prefs: SharedPreferences? = null
private var prefsDeferred: Deferred<SharedPreferences>? = null
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
@@ -70,32 +75,54 @@ object AuthManager {
* Call this once from Application.onCreate() or MainActivity.onCreate().
*/
fun init(context: Context) {
if (prefs != null) return
if (prefsDeferred != null) return
synchronized(this) {
if (prefs != null) return
val masterKey =
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
if (prefsDeferred != null) return
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
val masterKey =
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
prefs =
EncryptedSharedPreferences.create(
PREFS_FILE,
masterKey,
context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
val p =
EncryptedSharedPreferences.create(
PREFS_FILE,
masterKey,
context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
_bottomNavItemsFlow.value = getBottomNavItems()
_themePreferenceFlow.value = getThemePreference()
_useDynamicColorsFlow.value = isUseDynamicColors()
_themePresetFlow.value = getThemePreset()
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode()
_tokenFlow.value = getToken()
// Initialize flows with values from prefs once loaded
_bottomNavItemsFlow.value = getBottomNavItemsInternal(p)
_themePreferenceFlow.value = getThemePreferenceInternal(p)
_useDynamicColorsFlow.value = isUseDynamicColorsInternal(p)
_themePresetFlow.value = getThemePresetInternal(p)
_bottomNavDisplayModeFlow.value = getBottomNavDisplayModeInternal(p)
_tokenFlow.value = getTokenInternal(p)
p
}
}
}
/**
* Retrieves the initialized [SharedPreferences] instance.
*
* WARNING: This method is synchronous and will block the caller thread (using [runBlocking])
* if the asynchronous initialization is still in progress. Callers should avoid invoking this
* on the main thread during early startup to prevent frame drops or potential ANRs.
*
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds.
*/
private fun requirePrefs(): SharedPreferences =
prefs ?: throw IllegalStateException("AuthManager not initialized. Call init(context) first.")
runBlocking {
val deferred =
prefsDeferred ?: throw IllegalStateException(
"AuthManager not initialized. Call init(context) first.",
)
kotlinx.coroutines.withTimeoutOrNull(2000) {
deferred.await()
} ?: throw IllegalStateException("AuthManager initialization timed out after 2 seconds.")
}
fun setWsAuthParam(param: String) {
requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply()
@@ -209,6 +236,15 @@ object AuthManager {
}
}
private fun getTokenInternal(p: SharedPreferences): String? {
val selectedId = p.getString(KEY_SELECTED_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
return if (selectedId != null) {
p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null)
} else {
p.getString(KEY_TOKEN, null)
}
}
fun setToken(token: String?) {
val selectedId = getSelectedProfileId()
if (selectedId != null) {
@@ -289,8 +325,10 @@ object AuthManager {
// start reset the theme to SYSTEM. Fix: round-trip via EncryptedSharedPreferences
// (the same store already used by token / host / port / auto_reconnect).
fun getThemePreference(): ThemePreference =
requirePrefs().getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
fun getThemePreference(): ThemePreference = getThemePreferenceInternal(requirePrefs())
private fun getThemePreferenceInternal(p: SharedPreferences): ThemePreference =
p.getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
runCatching { ThemePreference.valueOf(name) }.getOrNull()
} ?: ThemePreference.SYSTEM
@@ -299,15 +337,19 @@ object AuthManager {
_themePreferenceFlow.value = theme
}
fun isUseDynamicColors(): Boolean = requirePrefs().getBoolean(KEY_USE_DYNAMIC_COLORS, true)
fun isUseDynamicColors(): Boolean = isUseDynamicColorsInternal(requirePrefs())
private fun isUseDynamicColorsInternal(p: SharedPreferences): Boolean = p.getBoolean(KEY_USE_DYNAMIC_COLORS, true)
fun setUseDynamicColors(value: Boolean) {
requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply()
_useDynamicColorsFlow.value = value
}
fun getThemePreset(): ThemePreset =
requirePrefs().getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
fun getThemePreset(): ThemePreset = getThemePresetInternal(requirePrefs())
private fun getThemePresetInternal(p: SharedPreferences): ThemePreset =
p.getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
runCatching { ThemePreset.valueOf(name) }.getOrNull()
} ?: ThemePreset.DEFAULT
@@ -337,8 +379,10 @@ object AuthManager {
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen")
/** Returns the list of selected bottom-nav item keys (data-object names). */
fun getBottomNavItems(): List<String> {
val raw = requirePrefs().getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
fun getBottomNavItems(): List<String> = getBottomNavItemsInternal(requirePrefs())
private fun getBottomNavItemsInternal(p: SharedPreferences): List<String> {
val raw = p.getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
return raw.split(",").filter { it.isNotBlank() }
}
@@ -366,8 +410,10 @@ object AuthManager {
// ── Bottom Nav Display Mode ──────────────────────────────────────────
fun getBottomNavDisplayMode(): BottomNavDisplayMode =
requirePrefs().getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name ->
fun getBottomNavDisplayMode(): BottomNavDisplayMode = getBottomNavDisplayModeInternal(requirePrefs())
private fun getBottomNavDisplayModeInternal(p: SharedPreferences): BottomNavDisplayMode =
p.getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name ->
runCatching { BottomNavDisplayMode.valueOf(name) }.getOrNull()
} ?: BottomNavDisplayMode.ICON_AND_TEXT
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
@@ -12,7 +13,12 @@ import androidx.room.PrimaryKey
* thread. The [timestamp] field preserves original ordering even if Room
* reorders internally.
*/
@Entity(tableName = "chat_messages")
@Entity(
tableName = "chat_messages",
indices = [
Index(value = ["session_id", "timestamp"]),
],
)
data class ChatMessageEntity(
@PrimaryKey
val id: String,
@@ -4,12 +4,14 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 2,
version = 3,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -33,20 +35,31 @@ abstract class HermesDatabase : RoomDatabase() {
System.loadLibrary("sqlcipher")
val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword())
val migration2to3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).openHelperFactory(factory)
.addMigrations(migration2to3)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
/** Returns true if the database file starts with the SQLCipher magic header. */
private fun isSqlCipherDatabase(file: File): Boolean {
return try {
private fun isSqlCipherDatabase(file: File): Boolean =
try {
val header = ByteArray(16)
file.inputStream().use { it.read(header) }
// SQLCipher 4.x databases start with bytes that differ from
@@ -56,7 +69,6 @@ abstract class HermesDatabase : RoomDatabase() {
} catch (_: Exception) {
false // if we can't read it, treat as plaintext and delete
}
}
/** For testing — inject a custom instance. */
fun setForTest(db: HermesDatabase?) {