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
+368 -131

No files matched your search

@@ -14,25 +14,32 @@ 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
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class HermesControlApp : class HermesControlApp :
Application(), Application(),
SingletonImageLoader.Factory { SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AuthManager.init(this) AuthManager.init(this)
// Issue #478: guarantee a "Default" profile is always selected so there is no
// separate standalone/default code path anywhere in the app.
AuthManager.ensureDefaultProfile()
NetworkMonitor.init(this) NetworkMonitor.init(this)
appScope.launch {
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
// so the tab renders instantly when opened (the usage endpoint is slow on a // so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup. // cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this) AnalyticsPreloader.preload(this@HermesControlApp)
// Issue #890: silent once-per-version update check right after launch, // Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction. // so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch() UpdateNoticeManager.checkOnLaunch()
} }
}
override fun newImageLoader(context: PlatformContext): ImageLoader = override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader ImageLoader
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION" "com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION"
} }
private var baseContextLanguageCode: String = LocaleContextWrapper.SYSTEM_LANGUAGE
/** /**
* Apply the user-selected display language before any view is inflated. * Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store * Reads the persisted code from [AuthManager]; an uninitialized store
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
val code = val code =
runCatching { AuthManager.getAppLanguage() } runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE) .getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
baseContextLanguageCode = code
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code)) super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
} }
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
consumeNotificationIntent(intent) consumeNotificationIntent(intent)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AuthManager.initializationState.collect { state ->
if (state == AuthManager.InitializationState.Ready) {
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
}
}
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState() val themePreference by AuthManager.themePreferenceFlow.collectAsState()
@@ -65,9 +89,37 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
val initState by AuthManager.initializationState.collectAsStateWithLifecycle()
when (initState) {
AuthManager.InitializationState.Loading -> {
LoadingState()
}
AuthManager.InitializationState.Ready -> {
val savedLanguage = AuthManager.getAppLanguage()
if (savedLanguage != baseContextLanguageCode) {
// The next activity attaches with loaded preferences. Keep navigation
// gated across recompositions until that recreation completes (#1171).
LaunchedEffect(savedLanguage) {
recreate()
}
LoadingState()
} else {
MainNavigation() MainNavigation()
} }
} }
AuthManager.InitializationState.Failed -> {
ErrorState(
message = stringResource(R.string.auth_init_failed),
onRetry = {
AuthManager.init(applicationContext)
},
)
}
}
}
}
} }
} }
@@ -82,9 +134,6 @@ class MainActivity : ComponentActivity() {
NotificationHelper.setAppForeground(this, true) NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed() ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this) NotificationHelper.stop(this)
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
} }
override fun onPause() { override fun onPause() {
@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
class ServerStore( class ServerStore private constructor(
private val dataStore: DataStore<ServerStoreState>, private val dataStore: DataStore<ServerStoreState>,
private val scope: CoroutineScope, private val scope: CoroutineScope,
initial: ServerStoreState,
) { ) {
private val _stateFlow: MutableStateFlow<ServerStoreState> private val _stateFlow = MutableStateFlow(initial)
val stateFlow: StateFlow<ServerStoreState> val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
init { companion object {
val initial = // Do not expose a default snapshot before disk/migrations finish (issue #1171).
runBlocking(Dispatchers.IO) { suspend fun create(
try { dataStore: DataStore<ServerStoreState>,
dataStore.data.first().selfHealed() scope: CoroutineScope,
} catch (e: Exception) { ): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
ServerStoreSerializer.defaultValue
}
}
_stateFlow = MutableStateFlow(initial)
stateFlow = _stateFlow.asStateFlow()
} }
fun getLatestState(): ServerStoreState = _stateFlow.value 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.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
/** /**
* Singleton that manages encrypted storage of the Hermes dashboard token * Singleton that manages encrypted storage of the Hermes dashboard token
@@ -128,33 +131,44 @@ object AuthManager {
* 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().
*/ */
fun init(context: Context) { enum class InitializationState { Loading, Ready, Failed }
if (_serverStore != null) return
synchronized(this) {
if (_serverStore != null) return
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) {
synchronized(this) {
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
_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 = val dataStore =
androidx.datastore.core.DataStoreFactory.create( androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer, serializer = ServerStoreSerializer,
migrations = migrations = listOf(ServerStoreMigration(context), ServerUrlMigration()),
listOf( scope = scope,
ServerStoreMigration(context), ) { context.filesDir.resolve("server_store.json") }
ServerUrlMigration(), val store = ServerStore.create(dataStore, scope)
),
) {
context.filesDir.resolve("server_store.json")
}
val scope = CoroutineScope(Dispatchers.IO)
appScope = scope
val store = ServerStore(dataStore, scope)
_serverStore = store _serverStore = store
_selectedProfileFlow.value = store.getLatestState().selectedProfileId ensureDefaultProfile()
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
if (prefsDeferred == null) {
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val p = val p =
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
@@ -165,53 +179,61 @@ object AuthManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
migrateLegacyDefaultIfNeeded(p) migrateLegacyDefaultIfNeeded(p)
_tokenFlow.value = getTokenInternal(p) synchronized(this@AuthManager) {
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() } readyPrefs = p
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()
// Initialize the encrypted cookie store (issue #470). The legacy _selectedProfileFlow.value = state.selectedProfileId
// session-cookie prefs are passed as a Deferred so existing gated _baseUrlFlow.value = state.resolvedBaseUrl
// sessions can be migrated on first load WITHOUT blocking startup. val profileId = normalizedProfileId(state.selectedProfileId)
// The Deferred is created above, before this call. CookieManager.initialize(context, prefs, profileId)
val initialProfileId = CookieManager.useStore(profileId)
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
CookieManager.initialize(context, prefsDeferred, initialProfileId)
scope.launch {
store.stateFlow.collect { state ->
_themePreferenceFlow.value = state.themePreference _themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors _useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset _themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale _chatFontScaleFlow.value = state.chatFontScale
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile. scope.launch {
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) } 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
} }
} }
} }
} }
/** /** Suspend without blocking the caller or imposing a keystore startup deadline. */
* Retrieves the initialized [SharedPreferences] instance. suspend fun awaitInitialization() {
* checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
* 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 =
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.")
} }
internal suspend fun awaitPrefs(): SharedPreferences {
awaitInitialization()
return requirePrefs()
}
/** Synchronous APIs are ready-only; entry points must await initialization first. */
private fun requirePrefs(): SharedPreferences =
checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
fun setWsAuthParam(param: String) { fun setWsAuthParam(param: String) {
serverStore.update { it.copy(wsAuthParam = param) } serverStore.update { it.copy(wsAuthParam = param) }
} }
@@ -259,17 +281,33 @@ object AuthManager {
// ── Database Master Password ───────────────────────────────────────── // ── Database Master Password ─────────────────────────────────────────
fun getDatabasePassword(): ByteArray { suspend fun getDatabasePassword(): ByteArray =
val prefs = requirePrefs() withContext(Dispatchers.IO) {
var dbPasswordBase64 = prefs.getString("db_password", null) val prefs = awaitPrefs()
if (dbPasswordBase64 == null) { synchronized(this@AuthManager) {
val random = java.security.SecureRandom() cachedDatabasePassword?.let { return@synchronized it.copyOf() }
val newPassword = ByteArray(32) val stored = prefs.getString("db_password", null)
random.nextBytes(newPassword) val password =
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP) if (stored == null) {
prefs.edit().putString("db_password", dbPasswordBase64).apply() 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 ────────────────────────────────────────────── // ── Connection Profiles ──────────────────────────────────────────────
@@ -387,15 +425,15 @@ object AuthManager {
token: String?, token: String?,
) { ) {
requirePrefs().edit().putString("token_$profileId", token).apply() requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) { if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
if (token == null) ActiveSessionHolder.clear() if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken // B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh // to prevent stale tokens during ticket refresh
synchronized(this) { synchronized(this) {
cachedToken = token cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
tokenInitialized = true tokenInitialized = true
_tokenFlow.value = cachedToken
} }
_tokenFlow.value = token
} }
} }
@@ -439,6 +477,9 @@ object AuthManager {
*/ */
fun setActiveProfileId(id: String?) { fun setActiveProfileId(id: String?) {
val normalized = id?.takeIf { it.isNotBlank() } 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 _activeProfileId.value = normalized
runCatching { runCatching {
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply() 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 // For testing purposes
fun resetAuthStateForTest() { fun resetAuthStateForTest() {
synchronized(this) { synchronized(this) {
@@ -489,6 +537,14 @@ object AuthManager {
tokenInitialized = false tokenInitialized = false
_serverStore = null _serverStore = null
prefsDeferred = 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 { appScope?.let {
try { try {
it.cancel() it.cancel()
@@ -7,6 +7,8 @@ import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL import androidx.sqlite.execSQL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import java.io.File import java.io.File
@@ -79,8 +81,12 @@ abstract class HermesDatabase : RoomDatabase() {
} }
} }
fun get(context: Context): HermesDatabase = suspend fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) { 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 // SQLCipher can't open plaintext SQLite databases — if an old
// unencrypted DB exists (v1), delete it so Room + SQLCipher can // unencrypted DB exists (v1), delete it so Room + SQLCipher can
// create an encrypted replacement from scratch. // create an encrypted replacement from scratch.
@@ -93,7 +99,7 @@ abstract class HermesDatabase : RoomDatabase() {
System.loadLibrary("sqlcipher") System.loadLibrary("sqlcipher")
val driver = val driver =
SQLCipherDriver( SQLCipherDriver(
AuthManager.getDatabasePassword(), password,
null, null,
null, null,
) )
@@ -114,6 +120,7 @@ abstract class HermesDatabase : RoomDatabase() {
.build() .build()
.also { instance = it } .also { instance = it }
} }
}
/** Returns true if the database file starts with the SQLCipher magic header. */ /** Returns true if the database file starts with the SQLCipher magic header. */
private fun isSqlCipherDatabase(file: File): Boolean = private fun isSqlCipherDatabase(file: File): Boolean =
@@ -296,6 +296,7 @@ class ChatNotificationService : Service() {
*/ */
object NotificationHelper { object NotificationHelper {
fun start(context: Context) { fun start(context: Context) {
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) return
if (AuthManager.getToken().isNullOrBlank()) return if (AuthManager.getToken().isNullOrBlank()) return
// Only run the foreground service while a reply is actually pending // Only run the foreground service while a reply is actually pending
// (issue #794) — otherwise the mandatory persistent "Waiting for // (issue #794) — otherwise the mandatory persistent "Waiting for
@@ -11,14 +11,16 @@ import com.m57.hermescontrol.data.local.toUiModel
* UI state management and WebSocket event handling. * UI state management and WebSocket event handling.
*/ */
open class ChatPersistenceRepository( open class ChatPersistenceRepository(
private val dao: ChatMessageDao, private val daoProvider: suspend () -> ChatMessageDao,
) { ) {
constructor(dao: ChatMessageDao) : this({ dao })
/** Persist a single message for the given session. */ /** Persist a single message for the given session. */
suspend fun persistMessage( suspend fun persistMessage(
message: ChatMessage, message: ChatMessage,
sessionId: String, sessionId: String,
) { ) {
dao.upsert(message.toEntity(sessionId)) daoProvider().upsert(message.toEntity(sessionId))
} }
/** Persist multiple messages in one transaction. */ /** Persist multiple messages in one transaction. */
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
sessionId: String, sessionId: String,
) { ) {
val entities = messages.map { it.toEntity(sessionId) } val entities = messages.map { it.toEntity(sessionId) }
dao.upsertAll(entities) daoProvider().upsertAll(entities)
} }
/** Load cached messages for a session from Room. */ /** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> = suspend fun loadMessages(sessionId: String): List<ChatMessage> =
dao.getMessagesForSession(sessionId).map { it.toUiModel() } daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
/** Clear all cached messages for a session (e.g. after /undo rewind). */ /** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) { suspend fun clearMessagesForSession(sessionId: String) {
dao.deleteMessagesForSession(sessionId) daoProvider().deleteMessagesForSession(sessionId)
} }
} }
@@ -322,9 +322,9 @@ class ChatViewModel(
application: Application, application: Application,
private val startCleanup: Boolean, private val startCleanup: Boolean,
repo: ChatPersistenceRepository = repo: ChatPersistenceRepository =
ChatPersistenceRepository( ChatPersistenceRepository {
HermesDatabase.get(application).chatMessageDao(), HermesDatabase.get(application).chatMessageDao()
), },
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext), slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
+1
View File
@@ -1189,6 +1189,7 @@
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">Hermes Mobile</string>
<string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string> <string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string>
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string> <string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
<string name="providers_empty_title">لا يوجد موفرو OAuth</string> <string name="providers_empty_title">لا يوجد موفرو OAuth</string>
<string name="providers_empty_desc">سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق.</string> <string name="providers_empty_desc">سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق.</string>
<string name="providers_action_connect">اتصال</string> <string name="providers_action_connect">اتصال</string>
+2 -1
View File
@@ -1008,7 +1008,8 @@
<string name="landing_subtitle">Hermes Agent 제어판</string> <string name="landing_subtitle">Hermes Agent 제어판</string>
<string name="landing_action_auth_login">인증 로그인</string> <string name="landing_action_auth_login">인증 로그인</string>
<!-- Loading State --> <!-- Loading / Startup State (issue #1171) -->
<string name="auth_init_failed">앱 설정을 초기화하지 못했습니다</string>
<!-- Providers Screen --> <!-- Providers Screen -->
<string name="providers_empty_title">OAuth 제공자가 없습니다</string> <string name="providers_empty_title">OAuth 제공자가 없습니다</string>
+1
View File
@@ -1156,6 +1156,7 @@
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">Hermes Mobile</string>
<string name="landing_subtitle">Hermes Agent 控制面板</string> <string name="landing_subtitle">Hermes Agent 控制面板</string>
<string name="landing_action_auth_login">认证登录</string> <string name="landing_action_auth_login">认证登录</string>
<string name="auth_init_failed">应用设置初始化失败</string>
<string name="providers_empty_title">没有 OAuth 提供商</string> <string name="providers_empty_title">没有 OAuth 提供商</string>
<string name="providers_empty_desc">已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。</string> <string name="providers_empty_desc">已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。</string>
<string name="providers_action_connect">连接</string> <string name="providers_action_connect">连接</string>
+2 -1
View File
@@ -1303,7 +1303,8 @@
<string name="landing_subtitle">Control panel for Hermes Agent</string> <string name="landing_subtitle">Control panel for Hermes Agent</string>
<string name="landing_action_auth_login">Auth Login</string> <string name="landing_action_auth_login">Auth Login</string>
<!-- Loading State --> <!-- Loading / Startup State (issue #1171) -->
<string name="auth_init_failed">Failed to initialize app settings</string>
<!-- Providers Screen (issue #534) --> <!-- Providers Screen (issue #534) -->
<string name="providers_empty_title">No OAuth providers</string> <string name="providers_empty_title">No OAuth providers</string>
@@ -10,6 +10,7 @@ import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.async
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -84,16 +85,13 @@ class AuthManagerTest {
storeField.isAccessible = true storeField.isAccessible = true
storeField.set(AuthManager, null) storeField.set(AuthManager, null)
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
// Initialise AuthManager // Initialise AuthManager
AuthManager.init(testContext) AuthManager.init(testContext)
// Wait for async initialization to complete to prevent coroutine leaks // Wait for async initialization to complete to prevent coroutine leaks
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>
deferred?.await()
}
// Wait for ServerStore initialization to complete // Wait for ServerStore initialization to complete
AuthManager.serverStore.getLatestState() AuthManager.serverStore.getLatestState()
@@ -105,7 +103,7 @@ class AuthManagerTest {
// into later classes — leaked collectors re-touch deleted/recreated // into later classes — leaked collectors re-touch deleted/recreated
// server_store.json and surface as UncaughtExceptionsBeforeTest // server_store.json and surface as UncaughtExceptionsBeforeTest
// phantoms in whichever test class runs next. // phantoms in whichever test class runs next.
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp")
val tempFile = java.io.File(tempDir, "server_store.json") val tempFile = java.io.File(tempDir, "server_store.json")
if (tempFile.exists()) { if (tempFile.exists()) {
@@ -114,6 +112,99 @@ class AuthManagerTest {
unmockkAll() unmockkAll()
} }
@Test
fun delayedKeystoreDoesNotBlockCallerOrExpireAfterTwoSeconds() =
kotlinx.coroutines.runBlocking {
AuthManager.resetAndAwaitForTest()
val entered = java.util.concurrent.CountDownLatch(1)
val release = java.util.concurrent.CountDownLatch(1)
every { MasterKeys.getOrCreate(any()) } answers {
entered.countDown()
check(release.await(10, java.util.concurrent.TimeUnit.SECONDS))
"mockMasterKey"
}
AuthManager.init(testContext)
try {
org.junit.Assert.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS))
assertEquals(AuthManager.InitializationState.Loading, AuthManager.initializationState.value)
val waiter = async { AuthManager.awaitInitialization() }
kotlinx.coroutines.delay(2100)
org.junit.Assert.assertFalse(waiter.isCompleted)
release.countDown()
waiter.await()
assertEquals(AuthManager.InitializationState.Ready, AuthManager.initializationState.value)
} finally {
release.countDown()
}
}
@Test
fun failedKeystoreCanRetryWithoutClearingCredentials() =
kotlinx.coroutines.runBlocking {
AuthManager.resetAndAwaitForTest()
every { MasterKeys.getOrCreate(any()) } throws IllegalStateException("keystore unavailable")
AuthManager.init(testContext)
val failure = runCatching { AuthManager.awaitInitialization() }.exceptionOrNull()
org.junit.Assert.assertTrue(failure is IllegalStateException)
assertEquals(AuthManager.InitializationState.Failed, AuthManager.initializationState.value)
every { MasterKeys.getOrCreate(any()) } returns "mockMasterKey"
every { mockPrefs.getString("token_default", null) } returns "restored-token"
AuthManager.init(testContext)
AuthManager.awaitInitialization()
assertEquals("restored-token", AuthManager.getToken())
assertEquals(AuthManager.InitializationState.Ready, AuthManager.initializationState.value)
}
@Test
fun databaseKeyIsCreatedOnceAndCacheReturnsDefensiveCopies() =
kotlinx.coroutines.runBlocking {
every { mockPrefs.getString("db_password", null) } returns null
every { mockEditor.commit() } returns true
val first = AuthManager.getDatabasePassword()
val original = first.copyOf()
first.fill(0)
org.junit.Assert.assertArrayEquals(original, AuthManager.getDatabasePassword())
assertEquals(32, original.size)
verify(exactly = 1) { mockPrefs.getString("db_password", null) }
verify(exactly = 1) { mockEditor.commit() }
}
@Test
fun existingDatabaseKeyIsPreserved() =
kotlinx.coroutines.runBlocking {
val original = ByteArray(32) { it.toByte() }
every { mockPrefs.getString("db_password", null) } returns
java.util.Base64
.getEncoder()
.encodeToString(original)
org.junit.Assert.assertArrayEquals(original, AuthManager.getDatabasePassword())
verify(exactly = 0) { mockEditor.commit() }
}
@Test
fun failedDatabaseKeyWriteDoesNotCacheUnsavedPassword() =
kotlinx.coroutines.runBlocking {
every { mockPrefs.getString("db_password", null) } returns null
every { mockEditor.commit() } returns false
org.junit.Assert.assertTrue(runCatching { AuthManager.getDatabasePassword() }.isFailure)
every { mockEditor.commit() } returns true
assertEquals(32, AuthManager.getDatabasePassword().size)
verify(exactly = 2) { mockEditor.commit() }
}
@Test
fun defaultTokenUpdateRefreshesInheritedTokenCache() {
AuthManager.saveConnectionProfiles(listOf(ConnectionProfile(id = "other", name = "Other")))
every { mockPrefs.getString("token_other", null) } returns null
every { mockPrefs.getString("token_default", null) } returns "old"
AuthManager.setSelectedProfileId("other")
assertEquals("old", AuthManager.getToken())
every { mockPrefs.getString("token_default", null) } returns "new"
AuthManager.setProfileToken("default", "new")
assertEquals("new", AuthManager.getToken())
assertEquals("new", AuthManager.tokenFlow.value)
}
@Test @Test
fun testGetAndSetToken() { fun testGetAndSetToken() {
AuthManager.ensureDefaultProfile() AuthManager.ensureDefaultProfile()
@@ -491,13 +582,9 @@ class AuthManagerTest {
every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false
// Re-initialize so init() runs the one-time migration // Re-initialize so init() runs the one-time migration
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext) AuthManager.init(testContext)
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
field.isAccessible = true
(field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await()
}
// token should now be persisted under the default profile key, and the legacy key removed // token should now be persisted under the default profile key, and the legacy key removed
verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "legacy-standalone-token") } verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "legacy-standalone-token") }
@@ -542,13 +629,9 @@ class AuthManagerTest {
fun testActiveProfileId_restoredFromPrefsAfterRestart() { fun testActiveProfileId_restoredFromPrefsAfterRestart() {
every { mockPrefs.getString("active_profile_id", null) } returns "meow" every { mockPrefs.getString("active_profile_id", null) } returns "meow"
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext) AuthManager.init(testContext)
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
field.isAccessible = true
(field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await()
}
assertEquals("meow", AuthManager.activeProfileId.value) assertEquals("meow", AuthManager.activeProfileId.value)
@@ -557,7 +640,7 @@ class AuthManagerTest {
// prefs/DataStore state and break subsequent mockkObject(AuthManager) // prefs/DataStore state and break subsequent mockkObject(AuthManager)
// classes — the "Missing mocked calls" / UncaughtExceptionsBeforeTest // classes — the "Missing mocked calls" / UncaughtExceptionsBeforeTest
// suite-order failures). // suite-order failures).
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
} }
@Test @Test
@@ -90,13 +90,10 @@ class Issue647ProfileUrlTest {
storeField.isAccessible = true storeField.isAccessible = true
storeField.set(AuthManager, null) storeField.set(AuthManager, null)
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext) AuthManager.init(testContext)
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>
deferred?.await()
}
AuthManager.serverStore.getLatestState() AuthManager.serverStore.getLatestState()
} }
@@ -106,7 +103,7 @@ class Issue647ProfileUrlTest {
// into later classes — leaked collectors re-touch deleted/recreated // into later classes — leaked collectors re-touch deleted/recreated
// server_store.json and surface as UncaughtExceptionsBeforeTest // server_store.json and surface as UncaughtExceptionsBeforeTest
// phantoms in whichever test class runs next. // phantoms in whichever test class runs next.
AuthManager.resetAuthStateForTest() kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp")
val tempFile = java.io.File(tempDir, "server_store.json") val tempFile = java.io.File(tempDir, "server_store.json")
if (tempFile.exists()) tempFile.delete() if (tempFile.exists()) tempFile.delete()
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
@@ -10,6 +11,41 @@ class ChatPersistenceRepositoryTest {
private lateinit var dao: FakeChatMessageDao private lateinit var dao: FakeChatMessageDao
private lateinit var repository: ChatPersistenceRepository private lateinit var repository: ChatPersistenceRepository
@Test
fun providerIsLazyAndPendingWriteWaitsForDatabase() =
runTest {
val ready = kotlinx.coroutines.CompletableDeferred<Unit>()
var calls = 0
val lazyRepository =
ChatPersistenceRepository {
calls++
ready.await()
dao
}
assertEquals(0, calls)
val pending =
async {
lazyRepository.persistMessage(
ChatMessage(id = "pending", role = MessageRole.USER, content = "saved"),
"s",
)
}
kotlinx.coroutines.yield()
assertEquals(1, calls)
org.junit.Assert.assertFalse(pending.isCompleted)
ready.complete(Unit)
pending.await()
assertEquals("pending", dao.getMessagesForSession("s").single().id)
}
@Test
fun providerFailureIsNotReportedAsEmptyHistory() =
runTest {
val failure = IllegalStateException("database unavailable")
val failing = ChatPersistenceRepository { throw failure }
org.junit.Assert.assertSame(failure, runCatching { failing.loadMessages("s") }.exceptionOrNull())
}
@Before @Before
fun setup() { fun setup() {
dao = FakeChatMessageDao() dao = FakeChatMessageDao()