From 430a08364787adde2ef2fc3441e1e3f77267083d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:40:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(#471):=20implement=20DataStore-based=20ser?= =?UTF-8?q?ver=20configuration=20and=20self-h=E2=80=A6=20(#477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#471): implement DataStore-based server configuration and self-healing * refactor(#471): address Sourcery feedback on ANR risk and reset method splitting * refactor(#471): implement synchronous ServerStore initialization to fully remove ANR risk * feat(#471): show profile name, host and port on add profile, redirect to login on save, and support shortcut login buttons for existing profiles --- app/build.gradle.kts | 1 + .../com/m57/hermescontrol/ScreenRegistry.kt | 11 +- .../{model => config}/ConnectionProfile.kt | 3 +- .../data/config/ServerCollectionOps.kt | 44 +++ .../hermescontrol/data/config/ServerStore.kt | 82 +++++ .../data/config/ServerStoreMigration.kt | 129 ++++++++ .../data/config/ServerStoreState.kt | 26 ++ .../hermescontrol/data/local/AuthManager.kt | 292 ++++++++---------- .../java/com/m57/hermescontrol/theme/Theme.kt | 4 + .../ui/authlogin/AuthLoginScreen.kt | 27 ++ .../ui/authlogin/AuthLoginViewModel.kt | 31 +- .../ui/connect/ConnectViewModel.kt | 9 +- .../ui/settings/SettingsScreen.kt | 38 ++- .../ui/settings/SettingsViewModel.kt | 14 +- app/src/main/res/values/strings.xml | 2 + .../data/config/ServerStoreTest.kt | 101 ++++++ .../data/local/AuthManagerTest.kt | 208 +++++++------ .../ui/connect/ConnectViewModelTest.kt | 15 +- .../ui/settings/SettingsViewModelTest.kt | 23 +- gradle/libs.versions.toml | 2 + 20 files changed, 774 insertions(+), 288 deletions(-) rename app/src/main/java/com/m57/hermescontrol/data/{model => config}/ConnectionProfile.kt (83%) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/config/ServerCollectionOps.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f483f5ea..66f5e328 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -159,6 +159,7 @@ dependencies { // Encrypted storage implementation(libs.androidx.security.crypto) + implementation(libs.androidx.datastore) implementation("androidx.startup:startup-runtime:1.1.1") // Local database (Room) — message persistence diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index acee994d..58b0cf97 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -184,9 +184,14 @@ object ScreenRegistry { Icons.Filled.Settings, DrawerSection.INSPECT, ) { sessionId, openDrawer -> - SettingsScreenContent(onBack = { - NavigationController.goBack() - }) + SettingsScreenContent( + onBack = { + NavigationController.goBack() + }, + onNavigateToLogin = { + NavigationController.navigateTo(AuthLoginScreen) + }, + ) }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ConnectionProfile.kt similarity index 83% rename from app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt rename to app/src/main/java/com/m57/hermescontrol/data/config/ConnectionProfile.kt index 2fb94297..079772d5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ConnectionProfile.kt @@ -1,4 +1,5 @@ -package com.m57.hermescontrol.data.model +package com.m57.hermescontrol.data.config + import kotlinx.serialization.Serializable import java.util.UUID diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerCollectionOps.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerCollectionOps.kt new file mode 100644 index 00000000..fac7a9e8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerCollectionOps.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.data.config + +fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStoreState { + val updated = connectionProfiles.filter { it.id != profile.id } + profile + return copy(connectionProfiles = updated) +} + +fun ServerStoreState.removeServer(id: String): ServerStoreState { + val updated = connectionProfiles.filter { it.id != id } + val newSelected = if (selectedProfileId == id) null else selectedProfileId + return copy(connectionProfiles = updated, selectedProfileId = newSelected) +} + +fun ServerStoreState.switchToServer(id: String?): ServerStoreState = copy(selectedProfileId = id) + +fun ServerStoreState.selfHealed(): ServerStoreState { + val hasActive = connectionProfiles.any { it.id == selectedProfileId } + val newSelected = if (selectedProfileId != null && !hasActive) null else selectedProfileId + + val validItems = bottomNavItems.filter { it.isNotBlank() } + val finalBottomNavItems = + if (validItems.isEmpty()) { + listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen") + } else { + validItems + } + + return copy( + selectedProfileId = newSelected, + bottomNavItems = finalBottomNavItems, + ) +} + +val ServerStoreState.resolvedHost: String + get() { + val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId } + return selected?.host ?: host + } + +val ServerStoreState.resolvedPort: Int + get() { + val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId } + return selected?.port ?: port + } 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 new file mode 100644 index 00000000..09b0e515 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt @@ -0,0 +1,82 @@ +package com.m57.hermescontrol.data.config + +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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( + private val dataStore: DataStore, + private val scope: CoroutineScope, +) { + private val _stateFlow: MutableStateFlow + val stateFlow: StateFlow + val state: Flow + + init { + val initial = + runBlocking(Dispatchers.IO) { + try { + dataStore.data.first().selfHealed() + } catch (e: Exception) { + ServerStoreState() + } + } + _stateFlow = MutableStateFlow(initial) + stateFlow = _stateFlow.asStateFlow() + state = stateFlow + } + + fun getLatestState(): ServerStoreState = _stateFlow.value + + fun update(transform: (ServerStoreState) -> ServerStoreState) { + val current = getLatestState() + val updated = transform(current).selfHealed() + _stateFlow.value = updated + scope.launch(Dispatchers.IO) { + try { + dataStore.updateData { updated } + } catch (e: Exception) { + // Fail-safe + } + } + } +} + +object ServerStoreSerializer : Serializer { + private val json = + Json { + ignoreUnknownKeys = true + prettyPrint = false + } + + override val defaultValue: ServerStoreState = ServerStoreState() + + override suspend fun readFrom(input: InputStream): ServerStoreState = + try { + json.decodeFromString( + ServerStoreState.serializer(), + input.readBytes().decodeToString(), + ) + } catch (e: Exception) { + defaultValue + } + + override suspend fun writeTo( + t: ServerStoreState, + output: OutputStream, + ) { + val serialized = json.encodeToString(ServerStoreState.serializer(), t) + output.write(serialized.toByteArray()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt new file mode 100644 index 00000000..bfa2b28a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt @@ -0,0 +1,129 @@ +package com.m57.hermescontrol.data.config + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataMigration +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.theme.BottomNavDisplayMode +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import kotlinx.serialization.decodeFromString + +class ServerStoreMigration( + private val context: Context, +) : DataMigration { + companion object { + private const val PREFS_FILE = "hermes_secure_prefs" + private const val KEY_MIGRATED = "migrated_to_datastore" + } + + override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean { + val prefs = getPrefs() ?: return false + return !prefs.getBoolean(KEY_MIGRATED, false) + } + + override suspend fun migrate(currentData: ServerStoreState): ServerStoreState { + val prefs = getPrefs() ?: return currentData + Log.d("ServerStoreMigration", "Migrating legacy preferences to DataStore...") + + val host = prefs.getString("host", "127.0.0.1") ?: "127.0.0.1" + val port = prefs.getInt("port", 9119) + val autoReconnect = prefs.getBoolean("auto_reconnect", true) + + val themePrefString = prefs.getString("theme_preference", ThemePreference.SYSTEM.name) + val themePreference = + runCatching { ThemePreference.valueOf(themePrefString!!) } + .getOrDefault(ThemePreference.SYSTEM) + + val useDynamicColors = prefs.getBoolean("use_dynamic_colors", true) + + val themePresetString = prefs.getString("theme_preset", ThemePreset.DEFAULT.name) + val themePreset = + runCatching { ThemePreset.valueOf(themePresetString!!) } + .getOrDefault(ThemePreset.DEFAULT) + + val bottomNavDisplayModeString = + prefs.getString( + "bottom_nav_display_mode", + BottomNavDisplayMode.ICON_AND_TEXT.name, + ) + val bottomNavDisplayMode = + runCatching { BottomNavDisplayMode.valueOf(bottomNavDisplayModeString!!) } + .getOrDefault(BottomNavDisplayMode.ICON_AND_TEXT) + + val bottomNavItemsRaw = prefs.getString("bottom_nav_items", null) + val bottomNavItems = + bottomNavItemsRaw?.split(",")?.filter { it.isNotBlank() } + ?: listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen") + + val connectionProfilesRaw = prefs.getString("connection_profiles", null) + val connectionProfiles = + if (connectionProfilesRaw != null) { + try { + OkHttpProvider.json.decodeFromString>(connectionProfilesRaw) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + + val selectedProfileId = prefs.getString("selected_profile_id", null)?.takeIf { it.isNotBlank() } + + val pinnedModelsRaw = prefs.getString("pinned_models", null) + val pinnedModels = + if (pinnedModelsRaw != null) { + try { + OkHttpProvider.json.decodeFromString>(pinnedModelsRaw) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + + val wsAuthParam = prefs.getString("ws_auth_param", "token") ?: "token" + val typingEffectEnabled = prefs.getBoolean("typing_effect_enabled", true) + val typingEffectDelayMs = prefs.getInt("typing_effect_delay_ms", 30) + + return currentData.copy( + host = host, + port = port, + autoReconnect = autoReconnect, + themePreference = themePreference, + useDynamicColors = useDynamicColors, + themePreset = themePreset, + bottomNavDisplayMode = bottomNavDisplayMode, + bottomNavItems = bottomNavItems, + connectionProfiles = connectionProfiles, + selectedProfileId = selectedProfileId, + pinnedModels = pinnedModels, + wsAuthParam = wsAuthParam, + typingEffectEnabled = typingEffectEnabled, + typingEffectDelayMs = typingEffectDelayMs, + ) + } + + override suspend fun cleanUp() { + val prefs = getPrefs() ?: return + prefs.edit().putBoolean(KEY_MIGRATED, true).apply() + Log.d("ServerStoreMigration", "Migration cleanup complete.") + } + + private fun getPrefs(): android.content.SharedPreferences? = + try { + val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + PREFS_FILE, + masterKey, + context.applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (e: Exception) { + null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt new file mode 100644 index 00000000..5de21fa2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.config + +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.theme.BottomNavDisplayMode +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import kotlinx.serialization.Serializable + +@Serializable +data class ServerStoreState( + val host: String = "127.0.0.1", + val port: Int = 9119, + val autoReconnect: Boolean = true, + val themePreference: ThemePreference = ThemePreference.SYSTEM, + val useDynamicColors: Boolean = true, + val themePreset: ThemePreset = ThemePreset.DEFAULT, + val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT, + val bottomNavItems: List = + listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen"), + val connectionProfiles: List = emptyList(), + val selectedProfileId: String? = null, + val pinnedModels: List = emptyList(), + val wsAuthParam: String = "token", + val typingEffectEnabled: Boolean = true, + val typingEffectDelayMs: Int = 30, +) 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 f09656da..4e964759 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 @@ -4,9 +4,13 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys -import com.m57.hermescontrol.data.model.ConnectionProfile +import com.m57.hermescontrol.data.config.ConnectionProfile +import com.m57.hermescontrol.data.config.ServerStore +import com.m57.hermescontrol.data.config.ServerStoreMigration +import com.m57.hermescontrol.data.config.ServerStoreSerializer +import com.m57.hermescontrol.data.config.resolvedHost +import com.m57.hermescontrol.data.config.resolvedPort import com.m57.hermescontrol.data.model.PinnedModel -import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -14,12 +18,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString /** * Singleton that manages encrypted storage of the Hermes dashboard token @@ -31,31 +35,24 @@ object AuthManager { private const val PREFS_FILE = "hermes_secure_prefs" private const val KEY_TOKEN = "auth_token" - private const val KEY_HOST = "host" - private const val KEY_PORT = "port" - private const val KEY_AUTO_RECONNECT = "auto_reconnect" - private const val KEY_THEME_PREFERENCE = "theme_preference" - private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items" - private const val KEY_TYPING_EFFECT_ENABLED = "typing_effect_enabled" - private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms" - private const val KEY_CONNECTION_PROFILES = "connection_profiles" private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id" - private const val KEY_WS_AUTH_PARAM = "ws_auth_param" private const val KEY_SESSION_COOKIE = "session_cookie" - private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors" - private const val KEY_THEME_PRESET = "theme_preset" - private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode" - private const val KEY_PINNED_MODELS = "pinned_models" - - private const val DEFAULT_HOST = "127.0.0.1" - private const val DEFAULT_PORT = 9119 - private const val DEFAULT_AUTO_RECONNECT = true - private const val DEFAULT_TYPING_EFFECT_ENABLED = true - private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30 @Volatile private var prefsDeferred: Deferred? = null + @Volatile + private var _serverStore: ServerStore? = null + + @Volatile + private var appScope: CoroutineScope? = null + + val serverStore: ServerStore + get() = + _serverStore ?: throw IllegalStateException( + "AuthManager not initialized. Call init(context) first.", + ) + private val _bottomNavItemsFlow = MutableStateFlow>(emptyList()) val bottomNavItemsFlow: StateFlow> = _bottomNavItemsFlow.asStateFlow() @@ -79,32 +76,49 @@ object AuthManager { * Call this once from Application.onCreate() or MainActivity.onCreate(). */ fun init(context: Context) { - if (prefsDeferred != null) return + if (_serverStore != null) return synchronized(this) { - if (prefsDeferred != null) return - prefsDeferred = - CoroutineScope(Dispatchers.IO).async { - val masterKey = - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + if (_serverStore != null) return - val p = - EncryptedSharedPreferences.create( - PREFS_FILE, - masterKey, - context.applicationContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - - // Initialize flows with values from prefs once loaded - _bottomNavItemsFlow.value = getBottomNavItemsInternal(p) - _themePreferenceFlow.value = getThemePreferenceInternal(p) - _useDynamicColorsFlow.value = isUseDynamicColorsInternal(p) - _themePresetFlow.value = getThemePresetInternal(p) - _bottomNavDisplayModeFlow.value = getBottomNavDisplayModeInternal(p) - _tokenFlow.value = getTokenInternal(p) - p + val dataStore = + androidx.datastore.core.DataStoreFactory.create( + serializer = ServerStoreSerializer, + migrations = listOf(ServerStoreMigration(context)), + ) { + context.filesDir.resolve("server_store.json") } + + val scope = CoroutineScope(Dispatchers.IO) + appScope = scope + val store = ServerStore(dataStore, scope) + _serverStore = store + + scope.launch { + store.stateFlow.collect { state -> + _bottomNavItemsFlow.value = state.bottomNavItems + _themePreferenceFlow.value = state.themePreference + _useDynamicColorsFlow.value = state.useDynamicColors + _themePresetFlow.value = state.themePreset + _bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode + } + } + + if (prefsDeferred == null) { + prefsDeferred = + CoroutineScope(Dispatchers.IO).async { + val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + val p = + EncryptedSharedPreferences.create( + PREFS_FILE, + masterKey, + context.applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + _tokenFlow.value = getTokenInternal(p) + p + } + } } } @@ -129,7 +143,7 @@ object AuthManager { } fun setWsAuthParam(param: String) { - requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply() + serverStore.update { it.copy(wsAuthParam = param) } } // ── Session Cookie (for gated/dashboard REST API) ──────────────────── @@ -163,42 +177,18 @@ object AuthManager { // ── Connection Profiles ────────────────────────────────────────────── - fun getConnectionProfiles(): List { - val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList() - return try { - OkHttpProvider.json.decodeFromString>(json) - } catch (e: Exception) { - if (com.m57.hermescontrol.BuildConfig.DEBUG) { - android.util.Log.w("AuthManager", "Failed to parse connection profiles", e) - } - emptyList() - } - } + fun getConnectionProfiles(): List = serverStore.getLatestState().connectionProfiles fun saveConnectionProfiles(profiles: List) { - val json = OkHttpProvider.json.encodeToString(profiles) - requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply() + serverStore.update { it.copy(connectionProfiles = profiles) } } // ── Pinned Models ──────────────────────────────────────────────────── - fun getPinnedModels(): List { - val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList() - return try { - OkHttpProvider.json.decodeFromString>(json) - } catch (e: Exception) { - if (com.m57.hermescontrol.BuildConfig.DEBUG) { - android.util.Log.w("AuthManager", "Failed to parse pinned models", e) - } - emptyList() - } - } + fun getPinnedModels(): List = serverStore.getLatestState().pinnedModels - fun savePinnedModels(pinned: List) { - val json = - com.m57.hermescontrol.data.remote.OkHttpProvider.json - .encodeToString(pinned) - requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply() + fun savePinnedModels(pinned: List) { + serverStore.update { it.copy(pinnedModels = pinned) } } fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null) @@ -214,12 +204,12 @@ object AuthManager { } fun getSelectedProfileId(): String? { - val id = requirePrefs().getString(KEY_SELECTED_PROFILE_ID, null) + val id = serverStore.getLatestState().selectedProfileId return if (id.isNullOrBlank()) null else id } fun setSelectedProfileId(id: String?) { - requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply() + serverStore.update { it.copy(selectedProfileId = id) } synchronized(this) { tokenInitialized = false } @@ -236,8 +226,27 @@ object AuthManager { // For testing purposes fun resetTokenCacheForTest() { - cachedToken = null - tokenInitialized = false + synchronized(this) { + cachedToken = null + tokenInitialized = false + } + } + + // For testing purposes + fun resetAuthStateForTest() { + synchronized(this) { + cachedToken = null + tokenInitialized = false + _serverStore = null + prefsDeferred = null + appScope?.let { + try { + it.cancel() + } catch (e: Exception) { + } + } + appScope = null + } } fun getToken(): String? { @@ -258,7 +267,7 @@ object AuthManager { } private fun getTokenInternal(p: SharedPreferences): String? { - val selectedId = p.getString(KEY_SELECTED_PROFILE_ID, null)?.takeIf { it.isNotBlank() } + val selectedId = serverStore.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } return if (selectedId != null) { p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null) } else { @@ -282,101 +291,68 @@ object AuthManager { // ── Host ───────────────────────────────────────────────────────────── - fun getHost(): String { - val selectedId = getSelectedProfileId() - if (selectedId != null) { - val profile = getConnectionProfiles().firstOrNull { it.id == selectedId } - if (profile != null) return profile.host - } - return requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST - } + fun getHost(): String = serverStore.getLatestState().resolvedHost fun setHost(host: String) { val selectedId = getSelectedProfileId() if (selectedId != null) { - val profiles = - getConnectionProfiles().map { - if (it.id == selectedId) it.copy(host = host) else it - } - saveConnectionProfiles(profiles) + serverStore.update { state -> + val profiles = + state.connectionProfiles.map { + if (it.id == selectedId) it.copy(host = host) else it + } + state.copy(connectionProfiles = profiles) + } } else { - requirePrefs().edit().putString(KEY_HOST, host).apply() + serverStore.update { it.copy(host = host) } } } // ── Port ───────────────────────────────────────────────────────────── - fun getPort(): Int { - val selectedId = getSelectedProfileId() - if (selectedId != null) { - val profile = getConnectionProfiles().firstOrNull { it.id == selectedId } - if (profile != null) return profile.port - } - return requirePrefs().getInt(KEY_PORT, DEFAULT_PORT) - } + fun getPort(): Int = serverStore.getLatestState().resolvedPort fun setPort(port: Int) { val selectedId = getSelectedProfileId() if (selectedId != null) { - val profiles = - getConnectionProfiles().map { - if (it.id == selectedId) it.copy(port = port) else it - } - saveConnectionProfiles(profiles) + serverStore.update { state -> + val profiles = + state.connectionProfiles.map { + if (it.id == selectedId) it.copy(port = port) else it + } + state.copy(connectionProfiles = profiles) + } } else { - requirePrefs().edit().putInt(KEY_PORT, port).apply() + serverStore.update { it.copy(port = port) } } } // ── Auto-reconnect ─────────────────────────────────────────────────── - fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT) + fun isAutoReconnect(): Boolean = serverStore.getLatestState().autoReconnect fun setAutoReconnect(enabled: Boolean) { - requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply() + serverStore.update { it.copy(autoReconnect = enabled) } } // ── Theme preference ────────────────────────────────────────────────── - // B6 (Jun 18 2026, kanban t_86e9be9b): previously, SettingsScreen.kt let - // the user pick SYSTEM/LIGHT/DARK in a SingleChoiceSegmentedButtonRow - // (SettingsScreen.kt:213-238) and SettingsViewModel.onThemeChange - // (SettingsViewModel.kt:62-64) updated in-memory state — but - // SettingsViewModel.loadSettings() did NOT read the value back and - // SettingsViewModel.save() did NOT persist it. As a result, every cold - // start reset the theme to SYSTEM. Fix: round-trip via EncryptedSharedPreferences - // (the same store already used by token / host / port / auto_reconnect). - fun getThemePreference(): ThemePreference = getThemePreferenceInternal(requirePrefs()) - - private fun getThemePreferenceInternal(p: SharedPreferences): ThemePreference = - p.getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name -> - runCatching { ThemePreference.valueOf(name) }.getOrNull() - } ?: ThemePreference.SYSTEM + fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference fun setThemePreference(theme: ThemePreference) { - requirePrefs().edit().putString(KEY_THEME_PREFERENCE, theme.name).apply() - _themePreferenceFlow.value = theme + serverStore.update { it.copy(themePreference = theme) } } - fun isUseDynamicColors(): Boolean = isUseDynamicColorsInternal(requirePrefs()) - - private fun isUseDynamicColorsInternal(p: SharedPreferences): Boolean = p.getBoolean(KEY_USE_DYNAMIC_COLORS, true) + fun isUseDynamicColors(): Boolean = serverStore.getLatestState().useDynamicColors fun setUseDynamicColors(value: Boolean) { - requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply() - _useDynamicColorsFlow.value = value + serverStore.update { it.copy(useDynamicColors = value) } } - fun getThemePreset(): ThemePreset = getThemePresetInternal(requirePrefs()) - - private fun getThemePresetInternal(p: SharedPreferences): ThemePreset = - p.getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name -> - runCatching { ThemePreset.valueOf(name) }.getOrNull() - } ?: ThemePreset.DEFAULT + fun getThemePreset(): ThemePreset = serverStore.getLatestState().themePreset fun setThemePreset(preset: ThemePreset) { - requirePrefs().edit().putString(KEY_THEME_PRESET, preset.name).apply() - _themePresetFlow.value = preset + serverStore.update { it.copy(themePreset = preset) } } /** Convenience: build the base URL from current host + port. @@ -387,7 +363,7 @@ object AuthManager { /** Convenience: build the WebSocket URL with token query param. * NOTE: Token in query string — trusted local network only. */ fun wsUrl(): String { - val raw = requirePrefs().getString(KEY_WS_AUTH_PARAM, "token") + val raw = serverStore.getLatestState().wsAuthParam val authParam = if (raw.isNullOrBlank()) "token" else raw val credential = getToken().orEmpty() return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential" @@ -395,51 +371,33 @@ object AuthManager { // ── Bottom nav bar items ────────────────────────────────────────────── - /** Default bottom-nav items (NavKey data-object names). */ - private val DEFAULT_BOTTOM_NAV_ITEMS = - listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen") - /** Returns the list of selected bottom-nav item keys (data-object names). */ - fun getBottomNavItems(): List = getBottomNavItemsInternal(requirePrefs()) - - private fun getBottomNavItemsInternal(p: SharedPreferences): List { - val raw = p.getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS - return raw.split(",").filter { it.isNotBlank() } - } + fun getBottomNavItems(): List = serverStore.getLatestState().bottomNavItems /** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */ fun setBottomNavItems(items: List) { - requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply() - _bottomNavItemsFlow.value = items + serverStore.update { it.copy(bottomNavItems = items) } } // ── Typing Effect ─────────────────────────────────────────────────── - fun isTypingEffectEnabled(): Boolean = - requirePrefs().getBoolean(KEY_TYPING_EFFECT_ENABLED, DEFAULT_TYPING_EFFECT_ENABLED) + fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled fun setTypingEffectEnabled(enabled: Boolean) { - requirePrefs().edit().putBoolean(KEY_TYPING_EFFECT_ENABLED, enabled).apply() + serverStore.update { it.copy(typingEffectEnabled = enabled) } } - fun getTypingEffectDelayMs(): Int = - requirePrefs().getInt(KEY_TYPING_EFFECT_DELAY_MS, DEFAULT_TYPING_EFFECT_DELAY_MS) + fun getTypingEffectDelayMs(): Int = serverStore.getLatestState().typingEffectDelayMs fun setTypingEffectDelayMs(delayMs: Int) { - requirePrefs().edit().putInt(KEY_TYPING_EFFECT_DELAY_MS, delayMs).apply() + serverStore.update { it.copy(typingEffectDelayMs = delayMs) } } // ── Bottom Nav Display Mode ────────────────────────────────────────── - fun getBottomNavDisplayMode(): BottomNavDisplayMode = getBottomNavDisplayModeInternal(requirePrefs()) - - private fun getBottomNavDisplayModeInternal(p: SharedPreferences): BottomNavDisplayMode = - p.getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name -> - runCatching { BottomNavDisplayMode.valueOf(name) }.getOrNull() - } ?: BottomNavDisplayMode.ICON_AND_TEXT + fun getBottomNavDisplayMode(): BottomNavDisplayMode = serverStore.getLatestState().bottomNavDisplayMode fun setBottomNavDisplayMode(mode: BottomNavDisplayMode) { - requirePrefs().edit().putString(KEY_BOTTOM_NAV_DISPLAY_MODE, mode.name).apply() - _bottomNavDisplayModeFlow.value = mode + serverStore.update { it.copy(bottomNavDisplayMode = mode) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 4577239b..6723ed51 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -12,11 +12,15 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import kotlinx.serialization.Serializable +@Serializable enum class ThemePreference { SYSTEM, LIGHT, DARK } +@Serializable enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NEON_NOIR } +@Serializable enum class BottomNavDisplayMode { ICON_AND_TEXT, ICON_ONLY, TEXT_ONLY } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt index dded5df7..fdc0368f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -18,11 +18,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -126,6 +128,31 @@ fun AuthLoginScreen( Spacer(modifier = Modifier.height(8.dp)) + if (state.loggedInProfiles.isNotEmpty()) { + Text( + text = stringResource(R.string.auth_login_existing_profiles_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 8.dp), + ) + state.loggedInProfiles.forEach { profile -> + androidx.compose.material3.OutlinedButton( + onClick = { viewModel.useExistingProfile(profile.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.auth_login_use_profile, profile.name)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(8.dp)) + } + // Host field OutlinedTextField( value = state.host, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt index e03ad361..8cdac4a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.safeApiCall @@ -47,14 +48,42 @@ data class AuthLoginUiState( val authMode: DashboardAuthMode? = null, val connectionSuccess: Boolean = false, val errorMessage: String? = null, + val loggedInProfiles: List = emptyList(), ) class AuthLoginViewModel( private val app: Application, ) : ViewModel() { - private val _uiState = MutableStateFlow(AuthLoginUiState()) + private val _uiState = + MutableStateFlow( + AuthLoginUiState( + host = AuthManager.getHost(), + port = AuthManager.getPort().toString(), + ), + ) val uiState: StateFlow = _uiState.asStateFlow() + init { + loadLoggedInProfiles() + } + + fun loadLoggedInProfiles() { + val allProfiles = AuthManager.getConnectionProfiles() + val loggedIn = + allProfiles.filter { profile -> + val token = AuthManager.getProfileToken(profile.id) + !token.isNullOrBlank() + } + _uiState.update { it.copy(loggedInProfiles = loggedIn) } + } + + fun useExistingProfile(profileId: String) { + AuthManager.setSelectedProfileId(profileId) + ApiClient.rebuild() + HermesWsClient.connect() + _uiState.update { it.copy(connectionSuccess = true) } + } + companion object { private const val TAG = "AuthLoginVM" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 5af7c65a..94a00ba2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkError @@ -30,8 +31,8 @@ data class ConnectUiState( val errorMessage: String? = null, val profileName: String = "", val saveProfile: Boolean = false, - val profiles: List = emptyList(), - val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null, + val profiles: List = emptyList(), + val selectedProfile: ConnectionProfile? = null, ) class ConnectViewModel( @@ -76,7 +77,7 @@ class ConnectViewModel( _uiState.update { it.copy(saveProfile = value) } } - fun selectProfile(profile: com.m57.hermescontrol.data.model.ConnectionProfile?) { + fun selectProfile(profile: ConnectionProfile?) { if (profile == null) { AuthManager.setSelectedProfileId(null) _uiState.update { @@ -160,7 +161,7 @@ class ConnectViewModel( if (existingIndex >= 0) { currentProfiles[existingIndex].copy(host = state.host, port = port) } else { - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( name = state.profileName, host = state.host, port = port, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index c5a18cc2..29f9601f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -84,11 +85,20 @@ enum class SettingsTab { @Composable fun SettingsScreen( onBack: () -> Unit, + onNavigateToLogin: () -> Unit = {}, onLogout: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.navigateToLogin) { + if (state.navigateToLogin) { + onNavigateToLogin() + viewModel.clearNavigateToLogin() + } + } + var passwordVisible by remember { mutableStateOf(false) } var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) } @@ -615,19 +625,21 @@ private fun ProfileEditorDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( - value = token, - onValueChange = onTokenChange, - label = { Text(stringResource(R.string.settings_field_token)) }, - singleLine = true, - visualTransformation = - if (token.isNotEmpty()) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - }, - modifier = Modifier.fillMaxWidth(), - ) + if (isEditing) { + OutlinedTextField( + value = token, + onValueChange = onTokenChange, + label = { Text(stringResource(R.string.settings_field_token)) }, + singleLine = true, + visualTransformation = + if (token.isNotEmpty()) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + modifier = Modifier.fillMaxWidth(), + ) + } } }, confirmButton = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index baadca5a..76c16dd1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.ScreenRegistry +import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -34,7 +35,7 @@ data class SettingsUiState( val selectedNavItems: List = emptyList(), val typingEffectEnabled: Boolean = false, val typingEffectDelayMs: Int = 30, - val profiles: List = emptyList(), + val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT, @@ -49,6 +50,7 @@ data class SettingsUiState( val showDeleteConfirm: Boolean = false, val profileToDeleteId: String? = null, val profileToDeleteName: String = "", + val navigateToLogin: Boolean = false, ) class SettingsViewModel( @@ -209,14 +211,16 @@ class SettingsViewModel( } else { // Add new profile val newProfile = - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( name = name, host = host, port = port, ) profiles.add(newProfile) AuthManager.saveConnectionProfiles(profiles) - AuthManager.setProfileToken(newProfile.id, token) + AuthManager.setProfileToken(newProfile.id, "") + AuthManager.setSelectedProfileId(newProfile.id) + _uiState.update { it.copy(navigateToLogin = true) } } closeProfileDialog() @@ -224,6 +228,10 @@ class SettingsViewModel( ApiClient.rebuild() } + fun clearNavigateToLogin() { + _uiState.update { it.copy(navigateToLogin = false) } + } + // ── Delete confirmation ────────────────────────────────────────────── fun requestDeleteProfile(profileId: String) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 215ad3fd..9f49bd0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -781,6 +781,8 @@ Password is required Dashboard unreachable — check IP and port Connected! + Use existing logged-in profile: + Enter as %1$s Hermes Mobile diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt new file mode 100644 index 00000000..96fdb062 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.data.config + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ServerStoreTest { + @Test + fun testDefaultValues() { + val state = ServerStoreState() + assertEquals("127.0.0.1", state.host) + assertEquals(9119, state.port) + assertTrue(state.autoReconnect) + assertEquals("token", state.wsAuthParam) + assertTrue(state.connectionProfiles.isEmpty()) + assertNull(state.selectedProfileId) + } + + @Test + fun testPureOps_addOrReplaceServer() { + val state = ServerStoreState() + val profile = ConnectionProfile(id = "1", name = "Test", host = "10.0.0.1", port = 8080) + + val newState = state.addOrReplaceServer(profile) + assertEquals(1, newState.connectionProfiles.size) + assertEquals(profile, newState.connectionProfiles[0]) + + // Replace + val updatedProfile = profile.copy(host = "10.0.0.2") + val finalState = newState.addOrReplaceServer(updatedProfile) + assertEquals(1, finalState.connectionProfiles.size) + assertEquals("10.0.0.2", finalState.connectionProfiles[0].host) + } + + @Test + fun testPureOps_removeServer() { + val profile1 = ConnectionProfile(id = "1", name = "Test1", host = "10.0.0.1", port = 8080) + val profile2 = ConnectionProfile(id = "2", name = "Test2", host = "10.0.0.2", port = 8081) + val state = + ServerStoreState( + connectionProfiles = listOf(profile1, profile2), + selectedProfileId = "2", + ) + + val newState = state.removeServer("2") + assertEquals(1, newState.connectionProfiles.size) + assertEquals("1", newState.connectionProfiles[0].id) + assertNull(newState.selectedProfileId) + } + + @Test + fun testPureOps_switchToServer() { + val state = ServerStoreState(selectedProfileId = null) + val newState = state.switchToServer("prof-1") + assertEquals("prof-1", newState.selectedProfileId) + } + + @Test + fun testPureOps_selfHealed() { + // Test orphaned selected profile ID + val state = + ServerStoreState( + connectionProfiles = emptyList(), + selectedProfileId = "orphaned-id", + ) + val healed = state.selfHealed() + assertNull(healed.selectedProfileId) + + // Test empty bottomNavItems + val state2 = + ServerStoreState( + bottomNavItems = emptyList(), + ) + val healed2 = state2.selfHealed() + assertEquals(5, healed2.bottomNavItems.size) + assertEquals("ChatScreen", healed2.bottomNavItems[0]) + } + + @Test + fun testResolvedHostAndPort() { + val profile = ConnectionProfile(id = "prof-1", name = "Custom", host = "10.0.0.5", port = 8000) + val state = + ServerStoreState( + host = "127.0.0.1", + port = 9119, + connectionProfiles = listOf(profile), + selectedProfileId = "prof-1", + ) + + assertEquals("10.0.0.5", state.resolvedHost) + assertEquals(8000, state.resolvedPort) + + // Unselected + val unselectedState = state.copy(selectedProfileId = null) + assertEquals("127.0.0.1", unselectedState.resolvedHost) + assertEquals(9119, unselectedState.resolvedPort) + } +} 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 c189c640..a39baf1f 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 @@ -4,13 +4,27 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys -import io.mockk.* +import com.m57.hermescontrol.data.config.ConnectionProfile +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test +class TestContext( + private val tempDir: java.io.File, + baseContext: Context, +) : android.content.ContextWrapper(baseContext) { + override fun getFilesDir(): java.io.File = tempDir + + override fun getApplicationContext(): Context = this +} + class AuthManagerTest { private lateinit var mockPrefs: SharedPreferences private lateinit var mockEditor: SharedPreferences.Editor @@ -26,6 +40,24 @@ class AuthManagerTest { every { mockEditor.putString(any(), any()) } returns mockEditor every { mockEditor.putInt(any(), any()) } returns mockEditor every { mockEditor.putBoolean(any(), any()) } returns mockEditor + every { mockPrefs.getBoolean("migrated_to_datastore", any()) } returns true + + // Mock Log to prevent "Method d in android.util.Log not mocked" + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.e(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.i(any(), any()) } returns 0 + + // Mock filesDir to point to temporary directory for DataStore + val tempDir = java.io.File(System.getProperty("java.io.tmpdir")) + val testContext = TestContext(tempDir, mockContext) + + val tempFile = java.io.File(tempDir, "server_store.json") + if (tempFile.exists()) { + tempFile.delete() + } // Mock EncryptedSharedPreferences static methods mockkStatic(EncryptedSharedPreferences::class) @@ -47,8 +79,14 @@ class AuthManagerTest { field.isAccessible = true field.set(AuthManager, null) + val storeField = AuthManager::class.java.getDeclaredField("_serverStore") + storeField.isAccessible = true + storeField.set(AuthManager, null) + + AuthManager.resetAuthStateForTest() + // Initialise AuthManager - AuthManager.init(mockContext) + AuthManager.init(testContext) // Wait for async initialization to complete to prevent coroutine leaks kotlinx.coroutines.runBlocking { @@ -56,11 +94,17 @@ class AuthManagerTest { deferred?.await() } - AuthManager.resetTokenCacheForTest() + // Wait for ServerStore initialization to complete + AuthManager.serverStore.getLatestState() } @After fun tearDown() { + val tempDir = java.io.File(System.getProperty("java.io.tmpdir")) + val tempFile = java.io.File(tempDir, "server_store.json") + if (tempFile.exists()) { + tempFile.delete() + } unmockkAll() } @@ -86,38 +130,26 @@ class AuthManagerTest { @Test fun testGetAndSetHost() { - every { mockPrefs.getString("host", "127.0.0.1") } returns "192.168.1.1" - assertEquals("192.168.1.1", AuthManager.getHost()) - AuthManager.setHost("10.0.0.1") - verify { mockEditor.putString("host", "10.0.0.1") } - verify { mockEditor.apply() } + assertEquals("10.0.0.1", AuthManager.getHost()) } @Test fun testGetAndSetPort() { - every { mockPrefs.getInt("port", 9119) } returns 8080 - assertEquals(8080, AuthManager.getPort()) - AuthManager.setPort(9090) - verify { mockEditor.putInt("port", 9090) } - verify { mockEditor.apply() } + assertEquals(9090, AuthManager.getPort()) } @Test fun testGetAndSetAutoReconnect() { - every { mockPrefs.getBoolean("auto_reconnect", true) } returns false + AuthManager.setAutoReconnect(false) assertEquals(false, AuthManager.isAutoReconnect()) - - AuthManager.setAutoReconnect(true) - verify { mockEditor.putBoolean("auto_reconnect", true) } - verify { mockEditor.apply() } } @Test fun testBaseUrl() { - every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" - every { mockPrefs.getInt("port", 9119) } returns 1234 + AuthManager.setHost("hermes.local") + AuthManager.setPort(1234) assertEquals("http://hermes.local:1234/", AuthManager.baseUrl()) } @@ -141,16 +173,16 @@ class AuthManagerTest { @Test fun testWsUrl() { - every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" - every { mockPrefs.getInt("port", 9119) } returns 1234 + AuthManager.setHost("hermes.local") + AuthManager.setPort(1234) every { mockPrefs.getString("auth_token", null) } returns "token123" assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl()) } @Test fun testWsUrl_nullToken() { - every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" - every { mockPrefs.getInt("port", 9119) } returns 1234 + AuthManager.setHost("hermes.local") + AuthManager.setPort(1234) every { mockPrefs.getString("auth_token", null) } returns null assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl()) } @@ -159,29 +191,35 @@ class AuthManagerTest { @Test fun testGetToken_usesProfileTokenWhenSelected() { - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" + val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profile1)) every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token" + AuthManager.setSelectedProfileId("prof-1") assertEquals("profile-specific-token", AuthManager.getToken()) } @Test fun testGetToken_fallsBackToGlobalWhenProfileTokenMissing() { - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" + val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profile1)) every { mockPrefs.getString("token_prof-1", null) } returns null // no profile token every { mockPrefs.getString("auth_token", null) } returns "global-token" + AuthManager.setSelectedProfileId("prof-1") assertEquals("global-token", AuthManager.getToken()) } @Test fun testGetToken_returnsNullWhenNoProfileAndNoGlobal() { - every { mockPrefs.getString("selected_profile_id", null) } returns null every { mockPrefs.getString("auth_token", null) } returns null + AuthManager.setSelectedProfileId(null) assertNull(AuthManager.getToken()) } @Test fun testSetToken_writesToProfileTokenWhenSelected() { - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" + val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profile1)) + AuthManager.setSelectedProfileId("prof-1") AuthManager.setToken("new-profile-token") @@ -193,7 +231,7 @@ class AuthManagerTest { @Test fun testSetToken_writesToGlobalWhenNoSelectedProfile() { - every { mockPrefs.getString("selected_profile_id", null) } returns null + AuthManager.setSelectedProfileId(null) AuthManager.setToken("global-token") @@ -203,59 +241,58 @@ class AuthManagerTest { @Test fun testGetHost_usesProfileHostWhenSelected() { - val profileJson = """[{"id":"prof-1","name":"Work","host":"10.0.0.1","port":9220}]""" - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" - every { mockPrefs.getString("connection_profiles", null) } returns profileJson + val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220) + AuthManager.saveConnectionProfiles(listOf(profile)) + AuthManager.setSelectedProfileId("prof-1") assertEquals("10.0.0.1", AuthManager.getHost()) } @Test fun testGetPort_usesProfilePortWhenSelected() { - val profileJson = """[{"id":"prof-1","name":"Work","host":"10.0.0.1","port":9220}]""" - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" - every { mockPrefs.getString("connection_profiles", null) } returns profileJson + val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220) + AuthManager.saveConnectionProfiles(listOf(profile)) + AuthManager.setSelectedProfileId("prof-1") assertEquals(9220, AuthManager.getPort()) } @Test fun testGetHost_fallsBackToDefaultWhenSelectedProfileIdNotFound() { - every { mockPrefs.getString("selected_profile_id", null) } returns "nonexistent" - every { mockPrefs.getString("connection_profiles", null) } returns null - every { mockPrefs.getString("host", "127.0.0.1") } returns "192.168.1.1" + AuthManager.setSelectedProfileId("nonexistent") + AuthManager.saveConnectionProfiles(emptyList()) + AuthManager.setHost("192.168.1.1") assertEquals("192.168.1.1", AuthManager.getHost()) } @Test fun testSetHost_updatesProfileWhenSelected() { - val profileJson = """[{"id":"prof-1","name":"Home","host":"10.0.0.1","port":9119}]""" - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" - every { mockPrefs.getString("connection_profiles", null) } returns profileJson + val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profile)) + AuthManager.setSelectedProfileId("prof-1") AuthManager.setHost("10.0.0.99") - // Should have saved the updated profile with the new host - verify { mockEditor.putString(eq("connection_profiles"), any()) } - verify(exactly = 0) { mockEditor.putString("host", any()) } + val updated = AuthManager.getConnectionProfiles().first() + assertEquals("10.0.0.99", updated.host) } @Test fun testSetPort_updatesProfileWhenSelected() { - val profileJson = """[{"id":"prof-1","name":"Home","host":"10.0.0.1","port":9119}]""" - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" - every { mockPrefs.getString("connection_profiles", null) } returns profileJson + val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profile)) + AuthManager.setSelectedProfileId("prof-1") AuthManager.setPort(9999) - verify { mockEditor.putString(eq("connection_profiles"), any()) } - verify(exactly = 0) { mockEditor.putInt("port", any()) } + val updated = AuthManager.getConnectionProfiles().first() + assertEquals(9999, updated.port) } @Test fun testProfileTokenRoundTrip() { - every { mockPrefs.getString("selected_profile_id", null) } returns null + AuthManager.setSelectedProfileId(null) AuthManager.setProfileToken("prof-a", "token-a") verify { mockEditor.putString("token_prof-a", "token-a") } @@ -270,104 +307,98 @@ class AuthManagerTest { fun testConnectionProfiles_roundTrip() { val profiles = listOf( - com.m57.hermescontrol.data.model - .ConnectionProfile("a", "A", "10.0.0.1", 9119), - com.m57.hermescontrol.data.model - .ConnectionProfile("b", "B", "10.0.0.2", 9220), + ConnectionProfile("a", "A", "10.0.0.1", 9119), + ConnectionProfile("b", "B", "10.0.0.2", 9220), ) AuthManager.saveConnectionProfiles(profiles) - verify { mockEditor.putString(eq("connection_profiles"), any()) } - - // Mock read-back - val json = - """[{"id":"a","name":"A","host":"10.0.0.1","port":9119},""" + - """{"id":"b","name":"B","host":"10.0.0.2","port":9220}]""" - every { mockPrefs.getString("connection_profiles", null) } returns json - val loaded = AuthManager.getConnectionProfiles() assertEquals(2, loaded.size) assertEquals("A", loaded[0].name) assertEquals("10.0.0.2", loaded[1].host) } - @Test - fun testGetHost_fallsBackToGlobalWhenProfileNotFoundInList() { - val profileJson = """[{"id":"prof-1","name":"Work","host":"10.0.0.1","port":9119}]""" - every { mockPrefs.getString("selected_profile_id", null) } returns "other-id" - every { mockPrefs.getString("connection_profiles", null) } returns profileJson - every { mockPrefs.getString("host", "127.0.0.1") } returns "global-host" - - assertEquals("global-host", AuthManager.getHost()) - } - // ── TEST-11: Multi-profile token scenarios ───────────────────────── @Test fun testGetToken_switchesTokenWhenProfileChanges() { + val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) + val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) + // Profile A selected → returns token_a - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" every { mockPrefs.getString("token_prof-a", null) } returns "token-for-a" + AuthManager.setSelectedProfileId("prof-a") assertEquals("token-for-a", AuthManager.getToken()) // Switch to Profile B → returns token_b - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" every { mockPrefs.getString("token_prof-b", null) } returns "token-for-b" AuthManager.setSelectedProfileId("prof-b") // Required to clear token cache assertEquals("token-for-b", AuthManager.getToken()) // Switch back to Profile A → still returns token_a - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" + every { mockPrefs.getString("token_prof-a", null) } returns "token-for-a" AuthManager.setSelectedProfileId("prof-a") // Required to clear token cache assertEquals("token-for-a", AuthManager.getToken()) } @Test fun testGetToken_fallsBackToGlobalWhenSelectedProfileLacksToken() { + val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) + val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) + // Profile A is selected but has no token → falls back to global - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" every { mockPrefs.getString("token_prof-a", null) } returns null every { mockPrefs.getString("auth_token", null) } returns "shared-global-token" + AuthManager.setSelectedProfileId("prof-a") assertEquals("shared-global-token", AuthManager.getToken()) // Switch to Profile B, which also has no token → same global fallback - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" every { mockPrefs.getString("token_prof-b", null) } returns null + AuthManager.setSelectedProfileId("prof-b") assertEquals("shared-global-token", AuthManager.getToken()) } @Test fun testGetToken_selectiveProfileTokens_someNull() { + val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) + val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) + val profileC = ConnectionProfile("prof-c", "Profile C", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC)) + // Profile A has a token - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" every { mockPrefs.getString("token_prof-a", null) } returns "token-a" + AuthManager.setSelectedProfileId("prof-a") assertEquals("token-a", AuthManager.getToken()) // Profile B has no token, but global exists - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" every { mockPrefs.getString("token_prof-b", null) } returns null every { mockPrefs.getString("auth_token", null) } returns "fallback" - AuthManager.setSelectedProfileId("prof-b") // Required to clear token cache + AuthManager.setSelectedProfileId("prof-b") assertEquals("fallback", AuthManager.getToken()) // Profile C has its own token too - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-c" every { mockPrefs.getString("token_prof-c", null) } returns "token-c" - AuthManager.setSelectedProfileId("prof-c") // Required to clear token cache + AuthManager.setSelectedProfileId("prof-c") assertEquals("token-c", AuthManager.getToken()) } @Test fun testSetToken_switchingProfiles_maintainsSeparateTokens() { + val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) + val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) + // Set a token while Profile A is selected - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" + AuthManager.setSelectedProfileId("prof-a") AuthManager.setToken("set-while-on-profile-a") verify { mockEditor.putString("token_prof-a", "set-while-on-profile-a") } verify { mockEditor.apply() } // Switch to Profile B - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" + AuthManager.setSelectedProfileId("prof-b") // Set a different token for Profile B AuthManager.setToken("set-while-on-profile-b") @@ -377,7 +408,10 @@ class AuthManagerTest { @Test fun testSetToken_toNull_clearsProfileToken() { - every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" + val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) + AuthManager.saveConnectionProfiles(listOf(profileA)) + + AuthManager.setSelectedProfileId("prof-a") AuthManager.setToken(null) @@ -387,10 +421,10 @@ class AuthManagerTest { @Test fun testGetSelectedProfileId_returnsNullForEmptyString() { - every { mockPrefs.getString("selected_profile_id", null) } returns "" + AuthManager.setSelectedProfileId("") assertNull(AuthManager.getSelectedProfileId()) - every { mockPrefs.getString("selected_profile_id", null) } returns " " + AuthManager.setSelectedProfileId(" ") assertNull(AuthManager.getSelectedProfileId()) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index fd286ad6..fc13fe7b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.connect import android.app.Application import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -388,7 +389,7 @@ class ConnectViewModelTest { @Test fun testSelectProfile_updatesState() { val profile = - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( id = "test-id", name = "Test Profile", host = "192.168.1.100", @@ -463,7 +464,7 @@ class ConnectViewModelTest { @Test fun testLoadSavedValues_withProfiles_loadsSelected() { val profile = - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( id = "prof-1", name = "Work", host = "10.0.0.1", @@ -488,7 +489,7 @@ class ConnectViewModelTest { @Test fun testLoadSavedValues_withStaleSelectedId_fallsBackToDefaults() { val profile = - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( id = "prof-1", name = "Work", host = "10.0.0.1", @@ -510,7 +511,7 @@ class ConnectViewModelTest { every { AuthManager.getProfileToken(any()) } returns null val profile = - com.m57.hermescontrol.data.model.ConnectionProfile( + ConnectionProfile( id = "prof-2", name = "NoToken", host = "10.0.0.2", @@ -574,10 +575,8 @@ class ConnectViewModelTest { fun testMultipleProfiles_loadedOnInit() { val profiles = listOf( - com.m57.hermescontrol.data.model - .ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), - com.m57.hermescontrol.data.model - .ConnectionProfile("b", "Beta", "10.0.0.2", 9220), + ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), + ConnectionProfile("b", "Beta", "10.0.0.2", 9220), ) every { AuthManager.getConnectionProfiles() } returns profiles every { AuthManager.getSelectedProfileId() } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 1fec8ced..cde4731e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -1,7 +1,7 @@ package com.m57.hermescontrol.ui.settings +import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.model.ConnectionProfile import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.ThemePreference @@ -218,4 +218,25 @@ class SettingsViewModelTest { verify(exactly = 0) { AuthManager.setSelectedProfileId(null) } verify { ApiClient.rebuild() } } + + @Test + fun testSaveProfileFromDialog_addsNewProfileAndTriggersLoginRedirection() { + every { AuthManager.getConnectionProfiles() } returns emptyList() + val viewModel = createViewModel() + + viewModel.openAddProfile() + viewModel.onDialogProfileNameChange("Test Profile") + viewModel.onDialogProfileHostChange("192.168.1.100") + viewModel.onDialogProfilePortChange("9999") + + viewModel.saveProfileFromDialog() + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.saveConnectionProfiles(any()) } + verify { AuthManager.setSelectedProfileId(any()) } + assertEquals(true, viewModel.uiState.value.navigateToLogin) + + viewModel.clearNavigateToLogin() + assertEquals(false, viewModel.uiState.value.navigateToLogin) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3e51426..1ea72ef8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ turbine = "1.2.0" coil = "2.7.0" room = "2.7.1" sqlcipher = "4.16.0" +datastore = "1.1.2" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } @@ -70,6 +71,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } # Database (Room) androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }