diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index c7241614..2ffcadd6 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 794a8ee2..5523362d 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt index a82b51de..a7cf427f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt @@ -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, private val scope: CoroutineScope, + initial: ServerStoreState, ) { - private val _stateFlow: MutableStateFlow - val stateFlow: StateFlow + private val _stateFlow = MutableStateFlow(initial) + val stateFlow: StateFlow = _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, + scope: CoroutineScope, + ): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed()) } fun getLatestState(): ServerStoreState = _stateFlow.value diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 1e23a503..ab99c0f7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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.asStateFlow() + + @Volatile + private var initialization: Deferred? = 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(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() diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index ab12e8b5..16741b0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index ce7e79ab..1026ad08 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index be0a197a..a2acc530 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -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 = - 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 4240a64a..3ed442b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 979a86cf..f8470fde 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1189,6 +1189,7 @@ Hermes Mobile لوحة التحكم لـ Hermes Agent تسجيل الدخول والمصادقة +فشل في تهيئة إعدادات التطبيق لا يوجد موفرو OAuth سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق. اتصال diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6e41aff4..178ca427 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1008,7 +1008,8 @@ Hermes Agent 제어판 인증 로그인 - + + 앱 설정을 초기화하지 못했습니다 OAuth 제공자가 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8f537e16..f5212d20 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1156,6 +1156,7 @@ Hermes Mobile Hermes Agent 控制面板 认证登录 + 应用设置初始化失败 没有 OAuth 提供商 已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。 连接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2059463d..4e886710 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1303,7 +1303,8 @@ Control panel for Hermes Agent Auth Login - + + Failed to initialize app settings No OAuth providers diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 1c29fed8..05c58e06 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index 8a8869b6..cf8fb9bc 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -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() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt index 57a3e7ae..780e2751 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt @@ -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() + 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()