perf(#1171): async AuthManager startup with readiness gate and lazy database (#1182)

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-09-17 20:54:03 +03:00
1 parent 736433b326
commit 220779bf19
15 files changed
+413 -176

No files matched your search

@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
class ServerStore(
class ServerStore private constructor(
private val dataStore: DataStore<ServerStoreState>,
private val scope: CoroutineScope,
initial: ServerStoreState,
) {
private val _stateFlow: MutableStateFlow<ServerStoreState>
val stateFlow: StateFlow<ServerStoreState>
private val _stateFlow = MutableStateFlow(initial)
val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
init {
val initial =
runBlocking(Dispatchers.IO) {
try {
dataStore.data.first().selfHealed()
} catch (e: Exception) {
ServerStoreSerializer.defaultValue
}
}
_stateFlow = MutableStateFlow(initial)
stateFlow = _stateFlow.asStateFlow()
companion object {
// Do not expose a default snapshot before disk/migrations finish (issue #1171).
suspend fun create(
dataStore: DataStore<ServerStoreState>,
scope: CoroutineScope,
): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
}
fun getLatestState(): ServerStoreState = _stateFlow.value
@@ -20,9 +20,12 @@ import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
/**
* Singleton that manages encrypted storage of the Hermes dashboard token
@@ -128,33 +131,44 @@ object AuthManager {
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
*/
enum class InitializationState { Loading, Ready, Failed }
private val _initializationState = MutableStateFlow(InitializationState.Loading)
val initializationState: StateFlow<InitializationState> = _initializationState.asStateFlow()
@Volatile
private var initialization: Deferred<Unit>? = null
@Volatile
private var readyPrefs: SharedPreferences? = null
private var cachedDatabasePassword: ByteArray? = null
// Issue #1171: publish readiness only after persisted profiles and credentials are loaded.
fun init(context: Context) {
if (_serverStore != null) return
synchronized(this) {
if (_serverStore != null) return
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) {
context.filesDir.resolve("server_store.json")
}
val scope = CoroutineScope(Dispatchers.IO)
if (initialization != null && _initializationState.value != InitializationState.Failed) return
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
appScope?.cancel()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
appScope = scope
val store = ServerStore(dataStore, scope)
_serverStore = store
_selectedProfileFlow.value = store.getLatestState().selectedProfileId
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
if (prefsDeferred == null) {
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
_initializationState.value = InitializationState.Loading
readyPrefs = null
val prefs = CompletableDeferred<SharedPreferences>(scope.coroutineContext[kotlinx.coroutines.Job])
prefsDeferred = prefs
initialization =
scope.async {
try {
previousJob?.join()
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations = listOf(ServerStoreMigration(context), ServerUrlMigration()),
scope = scope,
) { context.filesDir.resolve("server_store.json") }
val store = ServerStore.create(dataStore, scope)
_serverStore = store
ensureDefaultProfile()
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val p =
EncryptedSharedPreferences.create(
@@ -165,52 +179,60 @@ object AuthManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
migrateLegacyDefaultIfNeeded(p)
_tokenFlow.value = getTokenInternal(p)
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
p
synchronized(this@AuthManager) {
readyPrefs = p
cachedToken = getTokenInternal(p)
tokenInitialized = true
_tokenFlow.value = cachedToken
_activeProfileId.value =
p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
}
prefs.complete(p)
val state = store.getLatestState()
_selectedProfileFlow.value = state.selectedProfileId
_baseUrlFlow.value = state.resolvedBaseUrl
val profileId = normalizedProfileId(state.selectedProfileId)
CookieManager.initialize(context, prefs, profileId)
CookieManager.useStore(profileId)
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
scope.launch {
store.stateFlow.collect { latest ->
_themePreferenceFlow.value = latest.themePreference
_useDynamicColorsFlow.value = latest.useDynamicColors
_themePresetFlow.value = latest.themePreset
_chatFontScaleFlow.value = latest.chatFontScale
syncCookieStoreForProfile(latest.selectedProfileId)
}
}
_initializationState.value = InitializationState.Ready
} catch (e: CancellationException) {
prefs.cancel(e)
throw e
} catch (e: Exception) {
prefs.completeExceptionally(e)
_initializationState.value = InitializationState.Failed
throw e
}
}
// Initialize the encrypted cookie store (issue #470). The legacy
// session-cookie prefs are passed as a Deferred so existing gated
// sessions can be migrated on first load WITHOUT blocking startup.
// The Deferred is created above, before this call.
val initialProfileId =
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
CookieManager.initialize(context, prefsDeferred, initialProfileId)
scope.launch {
store.stateFlow.collect { state ->
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
}
}
}
}
/**
* 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.
*/
/** Suspend without blocking the caller or imposing a keystore startup deadline. */
suspend fun awaitInitialization() {
checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
}
internal suspend fun awaitPrefs(): SharedPreferences {
awaitInitialization()
return requirePrefs()
}
/** Synchronous APIs are ready-only; entry points must await initialization first. */
private fun requirePrefs(): SharedPreferences =
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.")
}
checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
fun setWsAuthParam(param: String) {
serverStore.update { it.copy(wsAuthParam = param) }
@@ -259,18 +281,34 @@ object AuthManager {
// ── Database Master Password ─────────────────────────────────────────
fun getDatabasePassword(): ByteArray {
val prefs = requirePrefs()
var dbPasswordBase64 = prefs.getString("db_password", null)
if (dbPasswordBase64 == null) {
val random = java.security.SecureRandom()
val newPassword = ByteArray(32)
random.nextBytes(newPassword)
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP)
prefs.edit().putString("db_password", dbPasswordBase64).apply()
suspend fun getDatabasePassword(): ByteArray =
withContext(Dispatchers.IO) {
val prefs = awaitPrefs()
synchronized(this@AuthManager) {
cachedDatabasePassword?.let { return@synchronized it.copyOf() }
val stored = prefs.getString("db_password", null)
val password =
if (stored == null) {
ByteArray(32).also { bytes ->
java.security.SecureRandom().nextBytes(bytes)
val encoded =
java.util.Base64
.getEncoder()
.encodeToString(bytes)
// Never create a database using a key that was not durably saved.
check(prefs.edit().putString("db_password", encoded).commit()) {
"Unable to persist database key"
}
}
} else {
java.util.Base64
.getDecoder()
.decode(stored)
}
cachedDatabasePassword = password
password.copyOf()
}
}
return android.util.Base64.decode(dbPasswordBase64, android.util.Base64.NO_WRAP)
}
// ── Connection Profiles ──────────────────────────────────────────────
@@ -387,15 +425,15 @@ object AuthManager {
token: String?,
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh
synchronized(this) {
cachedToken = token
cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
tokenInitialized = true
_tokenFlow.value = cachedToken
}
_tokenFlow.value = token
}
}
@@ -439,6 +477,9 @@ object AuthManager {
*/
fun setActiveProfileId(id: String?) {
val normalized = id?.takeIf { it.isNotBlank() }
// Flow first, persistence best-effort: a caller racing cold startup
// still gets the correct in-memory scope instead of an exception, and
// the write lands once initialization is Ready (#1171).
_activeProfileId.value = normalized
runCatching {
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply()
@@ -482,6 +523,13 @@ object AuthManager {
}
}
@VisibleForTesting
suspend fun resetAndAwaitForTest() {
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
resetAuthStateForTest()
previousJob?.join()
}
// For testing purposes
fun resetAuthStateForTest() {
synchronized(this) {
@@ -489,6 +537,14 @@ object AuthManager {
tokenInitialized = false
_serverStore = null
prefsDeferred = null
initialization?.cancel()
initialization = null
readyPrefs = null
cachedDatabasePassword?.fill(0)
cachedDatabasePassword = null
_initializationState.value = InitializationState.Loading
_tokenFlow.value = null
_selectedProfileFlow.value = null
appScope?.let {
try {
it.cancel()
@@ -7,6 +7,8 @@ import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import java.io.File
@@ -79,40 +81,45 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
// SQLCipher can't open plaintext SQLite databases — if an old
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
// create an encrypted replacement from scratch.
val dbFile = context.getDatabasePath("hermes_control.db")
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
dbFile.delete()
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
val password = AuthManager.getDatabasePassword()
synchronized(this@Companion) {
instance?.let { return@synchronized it }
// SQLCipher can't open plaintext SQLite databases — if an old
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
// create an encrypted replacement from scratch.
val dbFile = context.getDatabasePath("hermes_control.db")
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
dbFile.delete()
}
// Load SQLCipher native library before creating the driver
System.loadLibrary("sqlcipher")
val driver =
SQLCipherDriver(
password,
null,
null,
)
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).setDriver(driver)
.addMigrations(
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
// Load SQLCipher native library before creating the driver
System.loadLibrary("sqlcipher")
val driver =
SQLCipherDriver(
AuthManager.getDatabasePassword(),
null,
null,
)
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).setDriver(driver)
.addMigrations(
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
/** Returns true if the database file starts with the SQLCipher magic header. */