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

@@ -14,24 +14,31 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
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 :
Application(),
SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
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)
// 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
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
appScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
// 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
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this@HermesControlApp)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
}
}
override fun newImageLoader(context: PlatformContext): ImageLoader =
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver
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 kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
companion object {
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
"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.
* Reads the persisted code from [AuthManager]; an uninitialized store
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
val code =
runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
baseContextLanguageCode = code
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
}
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
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()
setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
@@ -65,7 +89,35 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainNavigation()
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()
}
}
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)
ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this)
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
override fun onPause() {
@@ -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. */
@@ -296,6 +296,7 @@ class ChatNotificationService : Service() {
*/
object NotificationHelper {
fun start(context: Context) {
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) return
if (AuthManager.getToken().isNullOrBlank()) return
// Only run the foreground service while a reply is actually pending
// (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.
*/
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. */
suspend fun persistMessage(
message: ChatMessage,
sessionId: String,
) {
dao.upsert(message.toEntity(sessionId))
daoProvider().upsert(message.toEntity(sessionId))
}
/** Persist multiple messages in one transaction. */
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
sessionId: String,
) {
val entities = messages.map { it.toEntity(sessionId) }
dao.upsertAll(entities)
daoProvider().upsertAll(entities)
}
/** Load cached messages for a session from Room. */
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). */
suspend fun clearMessagesForSession(sessionId: String) {
dao.deleteMessagesForSession(sessionId)
daoProvider().deleteMessagesForSession(sessionId)
}
}
@@ -322,9 +322,9 @@ class ChatViewModel(
application: Application,
private val startCleanup: Boolean,
repo: ChatPersistenceRepository =
ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(),
),
ChatPersistenceRepository {
HermesDatabase.get(application).chatMessageDao()
},
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
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_subtitle">لوحة التحكم لـ Hermes Agent</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_desc">سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق.</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_action_auth_login">인증 로그인</string>
<!-- Loading State -->
<!-- Loading / Startup State (issue #1171) -->
<string name="auth_init_failed">앱 설정을 초기화하지 못했습니다</string>
<!-- Providers Screen -->
<string name="providers_empty_title">OAuth 제공자가 없습니다</string>
+1
View File
@@ -1156,6 +1156,7 @@
<string name="landing_title">Hermes Mobile</string>
<string name="landing_subtitle">Hermes Agent 控制面板</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_desc">已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。</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_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) -->
<string name="providers_empty_title">No OAuth providers</string>
@@ -10,6 +10,7 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.async
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -84,16 +85,13 @@ class AuthManagerTest {
storeField.isAccessible = true
storeField.set(AuthManager, null)
AuthManager.resetAuthStateForTest()
kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
// Initialise AuthManager
AuthManager.init(testContext)
// Wait for async initialization to complete to prevent coroutine leaks
kotlinx.coroutines.runBlocking {
val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>
deferred?.await()
}
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
// Wait for ServerStore initialization to complete
AuthManager.serverStore.getLatestState()
@@ -105,7 +103,7 @@ class AuthManagerTest {
// into later classes — leaked collectors re-touch deleted/recreated
// server_store.json and surface as UncaughtExceptionsBeforeTest
// 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 tempFile = java.io.File(tempDir, "server_store.json")
if (tempFile.exists()) {
@@ -114,6 +112,99 @@ class AuthManagerTest {
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
fun testGetAndSetToken() {
AuthManager.ensureDefaultProfile()
@@ -491,13 +582,9 @@ class AuthManagerTest {
every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false
// Re-initialize so init() runs the one-time migration
AuthManager.resetAuthStateForTest()
kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext)
kotlinx.coroutines.runBlocking {
val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
field.isAccessible = true
(field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await()
}
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
// 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") }
@@ -542,13 +629,9 @@ class AuthManagerTest {
fun testActiveProfileId_restoredFromPrefsAfterRestart() {
every { mockPrefs.getString("active_profile_id", null) } returns "meow"
AuthManager.resetAuthStateForTest()
kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext)
kotlinx.coroutines.runBlocking {
val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
field.isAccessible = true
(field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await()
}
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
assertEquals("meow", AuthManager.activeProfileId.value)
@@ -557,7 +640,7 @@ class AuthManagerTest {
// prefs/DataStore state and break subsequent mockkObject(AuthManager)
// classes — the "Missing mocked calls" / UncaughtExceptionsBeforeTest
// suite-order failures).
AuthManager.resetAuthStateForTest()
kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
}
@Test
@@ -90,13 +90,10 @@ class Issue647ProfileUrlTest {
storeField.isAccessible = true
storeField.set(AuthManager, null)
AuthManager.resetAuthStateForTest()
kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() }
AuthManager.init(testContext)
kotlinx.coroutines.runBlocking {
val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>
deferred?.await()
}
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
AuthManager.serverStore.getLatestState()
}
@@ -106,7 +103,7 @@ class Issue647ProfileUrlTest {
// into later classes — leaked collectors re-touch deleted/recreated
// server_store.json and surface as UncaughtExceptionsBeforeTest
// 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 tempFile = java.io.File(tempDir, "server_store.json")
if (tempFile.exists()) tempFile.delete()
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
@@ -10,6 +11,41 @@ class ChatPersistenceRepositoryTest {
private lateinit var dao: FakeChatMessageDao
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
fun setup() {
dao = FakeChatMessageDao()