feat(#471): implement DataStore-based server configuration and self-h… (#477)

* 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
This commit is contained in:
M57
2026-07-06 22:40:44 +03:00
committed by GitHub
parent 5697405b19
commit 430a083647
20 changed files with 774 additions and 288 deletions
+1
View File
@@ -159,6 +159,7 @@ dependencies {
// Encrypted storage // Encrypted storage
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore)
implementation("androidx.startup:startup-runtime:1.1.1") implementation("androidx.startup:startup-runtime:1.1.1")
// Local database (Room) — message persistence // Local database (Room) — message persistence
@@ -184,9 +184,14 @@ object ScreenRegistry {
Icons.Filled.Settings, Icons.Filled.Settings,
DrawerSection.INSPECT, DrawerSection.INSPECT,
) { sessionId, openDrawer -> ) { sessionId, openDrawer ->
SettingsScreenContent(onBack = { SettingsScreenContent(
NavigationController.goBack() onBack = {
}) NavigationController.goBack()
},
onNavigateToLogin = {
NavigationController.navigateTo(AuthLoginScreen)
},
)
}, },
) )
} }
@@ -1,4 +1,5 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.config
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -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
}
@@ -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<ServerStoreState>,
private val scope: CoroutineScope,
) {
private val _stateFlow: MutableStateFlow<ServerStoreState>
val stateFlow: StateFlow<ServerStoreState>
val state: Flow<ServerStoreState>
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<ServerStoreState> {
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())
}
}
@@ -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<ServerStoreState> {
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<List<ConnectionProfile>>(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<List<PinnedModel>>(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
}
}
@@ -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<String> =
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen"),
val connectionProfiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
)
@@ -4,9 +4,13 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys 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.model.PinnedModel
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
@@ -14,12 +18,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
/** /**
* Singleton that manages encrypted storage of the Hermes dashboard token * 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 PREFS_FILE = "hermes_secure_prefs"
private const val KEY_TOKEN = "auth_token" 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_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_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 @Volatile
private var prefsDeferred: Deferred<SharedPreferences>? = null private var prefsDeferred: Deferred<SharedPreferences>? = 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<List<String>>(emptyList()) private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow() val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
@@ -79,32 +76,49 @@ object AuthManager {
* Call this once from Application.onCreate() or MainActivity.onCreate(). * Call this once from Application.onCreate() or MainActivity.onCreate().
*/ */
fun init(context: Context) { fun init(context: Context) {
if (prefsDeferred != null) return if (_serverStore != null) return
synchronized(this) { synchronized(this) {
if (prefsDeferred != null) return if (_serverStore != null) return
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
val masterKey =
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val p = val dataStore =
EncryptedSharedPreferences.create( androidx.datastore.core.DataStoreFactory.create(
PREFS_FILE, serializer = ServerStoreSerializer,
masterKey, migrations = listOf(ServerStoreMigration(context)),
context.applicationContext, ) {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, context.filesDir.resolve("server_store.json")
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 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) { 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) ──────────────────── // ── Session Cookie (for gated/dashboard REST API) ────────────────────
@@ -163,42 +177,18 @@ object AuthManager {
// ── Connection Profiles ────────────────────────────────────────────── // ── Connection Profiles ──────────────────────────────────────────────
fun getConnectionProfiles(): List<ConnectionProfile> { fun getConnectionProfiles(): List<ConnectionProfile> = serverStore.getLatestState().connectionProfiles
val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList()
return try {
OkHttpProvider.json.decodeFromString<List<ConnectionProfile>>(json)
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse connection profiles", e)
}
emptyList()
}
}
fun saveConnectionProfiles(profiles: List<ConnectionProfile>) { fun saveConnectionProfiles(profiles: List<ConnectionProfile>) {
val json = OkHttpProvider.json.encodeToString(profiles) serverStore.update { it.copy(connectionProfiles = profiles) }
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
} }
// ── Pinned Models ──────────────────────────────────────────────────── // ── Pinned Models ────────────────────────────────────────────────────
fun getPinnedModels(): List<PinnedModel> { fun getPinnedModels(): List<PinnedModel> = serverStore.getLatestState().pinnedModels
val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList()
return try {
OkHttpProvider.json.decodeFromString<List<PinnedModel>>(json)
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse pinned models", e)
}
emptyList()
}
}
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) { fun savePinnedModels(pinned: List<PinnedModel>) {
val json = serverStore.update { it.copy(pinnedModels = pinned) }
com.m57.hermescontrol.data.remote.OkHttpProvider.json
.encodeToString(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
} }
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null) fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
@@ -214,12 +204,12 @@ object AuthManager {
} }
fun getSelectedProfileId(): String? { fun getSelectedProfileId(): String? {
val id = requirePrefs().getString(KEY_SELECTED_PROFILE_ID, null) val id = serverStore.getLatestState().selectedProfileId
return if (id.isNullOrBlank()) null else id return if (id.isNullOrBlank()) null else id
} }
fun setSelectedProfileId(id: String?) { fun setSelectedProfileId(id: String?) {
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply() serverStore.update { it.copy(selectedProfileId = id) }
synchronized(this) { synchronized(this) {
tokenInitialized = false tokenInitialized = false
} }
@@ -236,8 +226,27 @@ object AuthManager {
// For testing purposes // For testing purposes
fun resetTokenCacheForTest() { fun resetTokenCacheForTest() {
cachedToken = null synchronized(this) {
tokenInitialized = false 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? { fun getToken(): String? {
@@ -258,7 +267,7 @@ object AuthManager {
} }
private fun getTokenInternal(p: SharedPreferences): String? { 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) { return if (selectedId != null) {
p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null) p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null)
} else { } else {
@@ -282,101 +291,68 @@ object AuthManager {
// ── Host ───────────────────────────────────────────────────────────── // ── Host ─────────────────────────────────────────────────────────────
fun getHost(): String { fun getHost(): String = serverStore.getLatestState().resolvedHost
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 setHost(host: String) { fun setHost(host: String) {
val selectedId = getSelectedProfileId() val selectedId = getSelectedProfileId()
if (selectedId != null) { if (selectedId != null) {
val profiles = serverStore.update { state ->
getConnectionProfiles().map { val profiles =
if (it.id == selectedId) it.copy(host = host) else it state.connectionProfiles.map {
} if (it.id == selectedId) it.copy(host = host) else it
saveConnectionProfiles(profiles) }
state.copy(connectionProfiles = profiles)
}
} else { } else {
requirePrefs().edit().putString(KEY_HOST, host).apply() serverStore.update { it.copy(host = host) }
} }
} }
// ── Port ───────────────────────────────────────────────────────────── // ── Port ─────────────────────────────────────────────────────────────
fun getPort(): Int { fun getPort(): Int = serverStore.getLatestState().resolvedPort
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 setPort(port: Int) { fun setPort(port: Int) {
val selectedId = getSelectedProfileId() val selectedId = getSelectedProfileId()
if (selectedId != null) { if (selectedId != null) {
val profiles = serverStore.update { state ->
getConnectionProfiles().map { val profiles =
if (it.id == selectedId) it.copy(port = port) else it state.connectionProfiles.map {
} if (it.id == selectedId) it.copy(port = port) else it
saveConnectionProfiles(profiles) }
state.copy(connectionProfiles = profiles)
}
} else { } else {
requirePrefs().edit().putInt(KEY_PORT, port).apply() serverStore.update { it.copy(port = port) }
} }
} }
// ── Auto-reconnect ─────────────────────────────────────────────────── // ── Auto-reconnect ───────────────────────────────────────────────────
fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT) fun isAutoReconnect(): Boolean = serverStore.getLatestState().autoReconnect
fun setAutoReconnect(enabled: Boolean) { fun setAutoReconnect(enabled: Boolean) {
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply() serverStore.update { it.copy(autoReconnect = enabled) }
} }
// ── Theme preference ────────────────────────────────────────────────── // ── 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()) fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
private fun getThemePreferenceInternal(p: SharedPreferences): ThemePreference =
p.getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
runCatching { ThemePreference.valueOf(name) }.getOrNull()
} ?: ThemePreference.SYSTEM
fun setThemePreference(theme: ThemePreference) { fun setThemePreference(theme: ThemePreference) {
requirePrefs().edit().putString(KEY_THEME_PREFERENCE, theme.name).apply() serverStore.update { it.copy(themePreference = theme) }
_themePreferenceFlow.value = theme
} }
fun isUseDynamicColors(): Boolean = isUseDynamicColorsInternal(requirePrefs()) fun isUseDynamicColors(): Boolean = serverStore.getLatestState().useDynamicColors
private fun isUseDynamicColorsInternal(p: SharedPreferences): Boolean = p.getBoolean(KEY_USE_DYNAMIC_COLORS, true)
fun setUseDynamicColors(value: Boolean) { fun setUseDynamicColors(value: Boolean) {
requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply() serverStore.update { it.copy(useDynamicColors = value) }
_useDynamicColorsFlow.value = value
} }
fun getThemePreset(): ThemePreset = getThemePresetInternal(requirePrefs()) fun getThemePreset(): ThemePreset = serverStore.getLatestState().themePreset
private fun getThemePresetInternal(p: SharedPreferences): ThemePreset =
p.getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
runCatching { ThemePreset.valueOf(name) }.getOrNull()
} ?: ThemePreset.DEFAULT
fun setThemePreset(preset: ThemePreset) { fun setThemePreset(preset: ThemePreset) {
requirePrefs().edit().putString(KEY_THEME_PRESET, preset.name).apply() serverStore.update { it.copy(themePreset = preset) }
_themePresetFlow.value = preset
} }
/** Convenience: build the base URL from current host + port. /** Convenience: build the base URL from current host + port.
@@ -387,7 +363,7 @@ object AuthManager {
/** Convenience: build the WebSocket URL with token query param. /** Convenience: build the WebSocket URL with token query param.
* NOTE: Token in query string — trusted local network only. */ * NOTE: Token in query string — trusted local network only. */
fun wsUrl(): String { 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 authParam = if (raw.isNullOrBlank()) "token" else raw
val credential = getToken().orEmpty() val credential = getToken().orEmpty()
return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential" return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential"
@@ -395,51 +371,33 @@ object AuthManager {
// ── Bottom nav bar items ────────────────────────────────────────────── // ── 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). */ /** Returns the list of selected bottom-nav item keys (data-object names). */
fun getBottomNavItems(): List<String> = getBottomNavItemsInternal(requirePrefs()) fun getBottomNavItems(): List<String> = serverStore.getLatestState().bottomNavItems
private fun getBottomNavItemsInternal(p: SharedPreferences): List<String> {
val raw = p.getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
return raw.split(",").filter { it.isNotBlank() }
}
/** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */ /** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */
fun setBottomNavItems(items: List<String>) { fun setBottomNavItems(items: List<String>) {
requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply() serverStore.update { it.copy(bottomNavItems = items) }
_bottomNavItemsFlow.value = items
} }
// ── Typing Effect ─────────────────────────────────────────────────── // ── Typing Effect ───────────────────────────────────────────────────
fun isTypingEffectEnabled(): Boolean = fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
requirePrefs().getBoolean(KEY_TYPING_EFFECT_ENABLED, DEFAULT_TYPING_EFFECT_ENABLED)
fun setTypingEffectEnabled(enabled: Boolean) { fun setTypingEffectEnabled(enabled: Boolean) {
requirePrefs().edit().putBoolean(KEY_TYPING_EFFECT_ENABLED, enabled).apply() serverStore.update { it.copy(typingEffectEnabled = enabled) }
} }
fun getTypingEffectDelayMs(): Int = fun getTypingEffectDelayMs(): Int = serverStore.getLatestState().typingEffectDelayMs
requirePrefs().getInt(KEY_TYPING_EFFECT_DELAY_MS, DEFAULT_TYPING_EFFECT_DELAY_MS)
fun setTypingEffectDelayMs(delayMs: Int) { fun setTypingEffectDelayMs(delayMs: Int) {
requirePrefs().edit().putInt(KEY_TYPING_EFFECT_DELAY_MS, delayMs).apply() serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
} }
// ── Bottom Nav Display Mode ────────────────────────────────────────── // ── Bottom Nav Display Mode ──────────────────────────────────────────
fun getBottomNavDisplayMode(): BottomNavDisplayMode = getBottomNavDisplayModeInternal(requirePrefs()) fun getBottomNavDisplayMode(): BottomNavDisplayMode = serverStore.getLatestState().bottomNavDisplayMode
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 setBottomNavDisplayMode(mode: BottomNavDisplayMode) { fun setBottomNavDisplayMode(mode: BottomNavDisplayMode) {
requirePrefs().edit().putString(KEY_BOTTOM_NAV_DISPLAY_MODE, mode.name).apply() serverStore.update { it.copy(bottomNavDisplayMode = mode) }
_bottomNavDisplayModeFlow.value = mode
} }
} }
@@ -12,11 +12,15 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import kotlinx.serialization.Serializable
@Serializable
enum class ThemePreference { SYSTEM, LIGHT, DARK } enum class ThemePreference { SYSTEM, LIGHT, DARK }
@Serializable
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NEON_NOIR } enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NEON_NOIR }
@Serializable
enum class BottomNavDisplayMode { ICON_AND_TEXT, ICON_ONLY, TEXT_ONLY } enum class BottomNavDisplayMode { ICON_AND_TEXT, ICON_ONLY, TEXT_ONLY }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
@@ -18,11 +18,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Bolt
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -126,6 +128,31 @@ fun AuthLoginScreen(
Spacer(modifier = Modifier.height(8.dp)) 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 // Host field
OutlinedTextField( OutlinedTextField(
value = state.host, value = state.host,
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
@@ -47,14 +48,42 @@ data class AuthLoginUiState(
val authMode: DashboardAuthMode? = null, val authMode: DashboardAuthMode? = null,
val connectionSuccess: Boolean = false, val connectionSuccess: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val loggedInProfiles: List<ConnectionProfile> = emptyList(),
) )
class AuthLoginViewModel( class AuthLoginViewModel(
private val app: Application, private val app: Application,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AuthLoginUiState()) private val _uiState =
MutableStateFlow(
AuthLoginUiState(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
),
)
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthLoginUiState> = _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 { companion object {
private const val TAG = "AuthLoginVM" private const val TAG = "AuthLoginVM"
} }
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
@@ -30,8 +31,8 @@ data class ConnectUiState(
val errorMessage: String? = null, val errorMessage: String? = null,
val profileName: String = "", val profileName: String = "",
val saveProfile: Boolean = false, val saveProfile: Boolean = false,
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(), val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null, val selectedProfile: ConnectionProfile? = null,
) )
class ConnectViewModel( class ConnectViewModel(
@@ -76,7 +77,7 @@ class ConnectViewModel(
_uiState.update { it.copy(saveProfile = value) } _uiState.update { it.copy(saveProfile = value) }
} }
fun selectProfile(profile: com.m57.hermescontrol.data.model.ConnectionProfile?) { fun selectProfile(profile: ConnectionProfile?) {
if (profile == null) { if (profile == null) {
AuthManager.setSelectedProfileId(null) AuthManager.setSelectedProfileId(null)
_uiState.update { _uiState.update {
@@ -160,7 +161,7 @@ class ConnectViewModel(
if (existingIndex >= 0) { if (existingIndex >= 0) {
currentProfiles[existingIndex].copy(host = state.host, port = port) currentProfiles[existingIndex].copy(host = state.host, port = port)
} else { } else {
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
name = state.profileName, name = state.profileName,
host = state.host, host = state.host,
port = port, port = port,
@@ -51,6 +51,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -84,11 +85,20 @@ enum class SettingsTab {
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToLogin: () -> Unit = {},
onLogout: () -> Unit = {}, onLogout: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.navigateToLogin) {
if (state.navigateToLogin) {
onNavigateToLogin()
viewModel.clearNavigateToLogin()
}
}
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) } var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) }
@@ -615,19 +625,21 @@ private fun ProfileEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( if (isEditing) {
value = token, OutlinedTextField(
onValueChange = onTokenChange, value = token,
label = { Text(stringResource(R.string.settings_field_token)) }, onValueChange = onTokenChange,
singleLine = true, label = { Text(stringResource(R.string.settings_field_token)) },
visualTransformation = singleLine = true,
if (token.isNotEmpty()) { visualTransformation =
PasswordVisualTransformation() if (token.isNotEmpty()) {
} else { PasswordVisualTransformation()
VisualTransformation.None } else {
}, VisualTransformation.None
modifier = Modifier.fillMaxWidth(), },
) modifier = Modifier.fillMaxWidth(),
)
}
} }
}, },
confirmButton = { confirmButton = {
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.ScreenRegistry import com.m57.hermescontrol.ScreenRegistry
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
@@ -34,7 +35,7 @@ data class SettingsUiState(
val selectedNavItems: List<String> = emptyList(), val selectedNavItems: List<String> = emptyList(),
val typingEffectEnabled: Boolean = false, val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(), val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null, val selectedProfileId: String? = null,
val renameProfileName: String = "", val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT, val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
@@ -49,6 +50,7 @@ data class SettingsUiState(
val showDeleteConfirm: Boolean = false, val showDeleteConfirm: Boolean = false,
val profileToDeleteId: String? = null, val profileToDeleteId: String? = null,
val profileToDeleteName: String = "", val profileToDeleteName: String = "",
val navigateToLogin: Boolean = false,
) )
class SettingsViewModel( class SettingsViewModel(
@@ -209,14 +211,16 @@ class SettingsViewModel(
} else { } else {
// Add new profile // Add new profile
val newProfile = val newProfile =
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
name = name, name = name,
host = host, host = host,
port = port, port = port,
) )
profiles.add(newProfile) profiles.add(newProfile)
AuthManager.saveConnectionProfiles(profiles) AuthManager.saveConnectionProfiles(profiles)
AuthManager.setProfileToken(newProfile.id, token) AuthManager.setProfileToken(newProfile.id, "")
AuthManager.setSelectedProfileId(newProfile.id)
_uiState.update { it.copy(navigateToLogin = true) }
} }
closeProfileDialog() closeProfileDialog()
@@ -224,6 +228,10 @@ class SettingsViewModel(
ApiClient.rebuild() ApiClient.rebuild()
} }
fun clearNavigateToLogin() {
_uiState.update { it.copy(navigateToLogin = false) }
}
// ── Delete confirmation ────────────────────────────────────────────── // ── Delete confirmation ──────────────────────────────────────────────
fun requestDeleteProfile(profileId: String) { fun requestDeleteProfile(profileId: String) {
+2
View File
@@ -781,6 +781,8 @@
<string name="auth_login_error_password_required">Password is required</string> <string name="auth_login_error_password_required">Password is required</string>
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string> <string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
<string name="auth_login_success">Connected!</string> <string name="auth_login_success">Connected!</string>
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
<string name="auth_login_use_profile">Enter as %1$s</string>
<!-- Landing Screen --> <!-- Landing Screen -->
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">Hermes Mobile</string>
@@ -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)
}
}
@@ -4,13 +4,27 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys 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.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
import org.junit.Test 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 { class AuthManagerTest {
private lateinit var mockPrefs: SharedPreferences private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor private lateinit var mockEditor: SharedPreferences.Editor
@@ -26,6 +40,24 @@ class AuthManagerTest {
every { mockEditor.putString(any(), any()) } returns mockEditor every { mockEditor.putString(any(), any()) } returns mockEditor
every { mockEditor.putInt(any(), any()) } returns mockEditor every { mockEditor.putInt(any(), any()) } returns mockEditor
every { mockEditor.putBoolean(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<String>()) } 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 // Mock EncryptedSharedPreferences static methods
mockkStatic(EncryptedSharedPreferences::class) mockkStatic(EncryptedSharedPreferences::class)
@@ -47,8 +79,14 @@ class AuthManagerTest {
field.isAccessible = true field.isAccessible = true
field.set(AuthManager, null) field.set(AuthManager, null)
val storeField = AuthManager::class.java.getDeclaredField("_serverStore")
storeField.isAccessible = true
storeField.set(AuthManager, null)
AuthManager.resetAuthStateForTest()
// Initialise AuthManager // Initialise AuthManager
AuthManager.init(mockContext) AuthManager.init(testContext)
// Wait for async initialization to complete to prevent coroutine leaks // Wait for async initialization to complete to prevent coroutine leaks
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
@@ -56,11 +94,17 @@ class AuthManagerTest {
deferred?.await() deferred?.await()
} }
AuthManager.resetTokenCacheForTest() // Wait for ServerStore initialization to complete
AuthManager.serverStore.getLatestState()
} }
@After @After
fun tearDown() { 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() unmockkAll()
} }
@@ -86,38 +130,26 @@ class AuthManagerTest {
@Test @Test
fun testGetAndSetHost() { 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") AuthManager.setHost("10.0.0.1")
verify { mockEditor.putString("host", "10.0.0.1") } assertEquals("10.0.0.1", AuthManager.getHost())
verify { mockEditor.apply() }
} }
@Test @Test
fun testGetAndSetPort() { fun testGetAndSetPort() {
every { mockPrefs.getInt("port", 9119) } returns 8080
assertEquals(8080, AuthManager.getPort())
AuthManager.setPort(9090) AuthManager.setPort(9090)
verify { mockEditor.putInt("port", 9090) } assertEquals(9090, AuthManager.getPort())
verify { mockEditor.apply() }
} }
@Test @Test
fun testGetAndSetAutoReconnect() { fun testGetAndSetAutoReconnect() {
every { mockPrefs.getBoolean("auto_reconnect", true) } returns false AuthManager.setAutoReconnect(false)
assertEquals(false, AuthManager.isAutoReconnect()) assertEquals(false, AuthManager.isAutoReconnect())
AuthManager.setAutoReconnect(true)
verify { mockEditor.putBoolean("auto_reconnect", true) }
verify { mockEditor.apply() }
} }
@Test @Test
fun testBaseUrl() { fun testBaseUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" AuthManager.setHost("hermes.local")
every { mockPrefs.getInt("port", 9119) } returns 1234 AuthManager.setPort(1234)
assertEquals("http://hermes.local:1234/", AuthManager.baseUrl()) assertEquals("http://hermes.local:1234/", AuthManager.baseUrl())
} }
@@ -141,16 +173,16 @@ class AuthManagerTest {
@Test @Test
fun testWsUrl() { fun testWsUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" AuthManager.setHost("hermes.local")
every { mockPrefs.getInt("port", 9119) } returns 1234 AuthManager.setPort(1234)
every { mockPrefs.getString("auth_token", null) } returns "token123" every { mockPrefs.getString("auth_token", null) } returns "token123"
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl()) assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
} }
@Test @Test
fun testWsUrl_nullToken() { fun testWsUrl_nullToken() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local" AuthManager.setHost("hermes.local")
every { mockPrefs.getInt("port", 9119) } returns 1234 AuthManager.setPort(1234)
every { mockPrefs.getString("auth_token", null) } returns null every { mockPrefs.getString("auth_token", null) } returns null
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl()) assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
} }
@@ -159,29 +191,35 @@ class AuthManagerTest {
@Test @Test
fun testGetToken_usesProfileTokenWhenSelected() { 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" every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token"
AuthManager.setSelectedProfileId("prof-1")
assertEquals("profile-specific-token", AuthManager.getToken()) assertEquals("profile-specific-token", AuthManager.getToken())
} }
@Test @Test
fun testGetToken_fallsBackToGlobalWhenProfileTokenMissing() { 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("token_prof-1", null) } returns null // no profile token
every { mockPrefs.getString("auth_token", null) } returns "global-token" every { mockPrefs.getString("auth_token", null) } returns "global-token"
AuthManager.setSelectedProfileId("prof-1")
assertEquals("global-token", AuthManager.getToken()) assertEquals("global-token", AuthManager.getToken())
} }
@Test @Test
fun testGetToken_returnsNullWhenNoProfileAndNoGlobal() { fun testGetToken_returnsNullWhenNoProfileAndNoGlobal() {
every { mockPrefs.getString("selected_profile_id", null) } returns null
every { mockPrefs.getString("auth_token", null) } returns null every { mockPrefs.getString("auth_token", null) } returns null
AuthManager.setSelectedProfileId(null)
assertNull(AuthManager.getToken()) assertNull(AuthManager.getToken())
} }
@Test @Test
fun testSetToken_writesToProfileTokenWhenSelected() { 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") AuthManager.setToken("new-profile-token")
@@ -193,7 +231,7 @@ class AuthManagerTest {
@Test @Test
fun testSetToken_writesToGlobalWhenNoSelectedProfile() { fun testSetToken_writesToGlobalWhenNoSelectedProfile() {
every { mockPrefs.getString("selected_profile_id", null) } returns null AuthManager.setSelectedProfileId(null)
AuthManager.setToken("global-token") AuthManager.setToken("global-token")
@@ -203,59 +241,58 @@ class AuthManagerTest {
@Test @Test
fun testGetHost_usesProfileHostWhenSelected() { fun testGetHost_usesProfileHostWhenSelected() {
val profileJson = """[{"id":"prof-1","name":"Work","host":"10.0.0.1","port":9220}]""" val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220)
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" AuthManager.saveConnectionProfiles(listOf(profile))
every { mockPrefs.getString("connection_profiles", null) } returns profileJson AuthManager.setSelectedProfileId("prof-1")
assertEquals("10.0.0.1", AuthManager.getHost()) assertEquals("10.0.0.1", AuthManager.getHost())
} }
@Test @Test
fun testGetPort_usesProfilePortWhenSelected() { fun testGetPort_usesProfilePortWhenSelected() {
val profileJson = """[{"id":"prof-1","name":"Work","host":"10.0.0.1","port":9220}]""" val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220)
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" AuthManager.saveConnectionProfiles(listOf(profile))
every { mockPrefs.getString("connection_profiles", null) } returns profileJson AuthManager.setSelectedProfileId("prof-1")
assertEquals(9220, AuthManager.getPort()) assertEquals(9220, AuthManager.getPort())
} }
@Test @Test
fun testGetHost_fallsBackToDefaultWhenSelectedProfileIdNotFound() { fun testGetHost_fallsBackToDefaultWhenSelectedProfileIdNotFound() {
every { mockPrefs.getString("selected_profile_id", null) } returns "nonexistent" AuthManager.setSelectedProfileId("nonexistent")
every { mockPrefs.getString("connection_profiles", null) } returns null AuthManager.saveConnectionProfiles(emptyList())
every { mockPrefs.getString("host", "127.0.0.1") } returns "192.168.1.1" AuthManager.setHost("192.168.1.1")
assertEquals("192.168.1.1", AuthManager.getHost()) assertEquals("192.168.1.1", AuthManager.getHost())
} }
@Test @Test
fun testSetHost_updatesProfileWhenSelected() { fun testSetHost_updatesProfileWhenSelected() {
val profileJson = """[{"id":"prof-1","name":"Home","host":"10.0.0.1","port":9119}]""" val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119)
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" AuthManager.saveConnectionProfiles(listOf(profile))
every { mockPrefs.getString("connection_profiles", null) } returns profileJson AuthManager.setSelectedProfileId("prof-1")
AuthManager.setHost("10.0.0.99") AuthManager.setHost("10.0.0.99")
// Should have saved the updated profile with the new host val updated = AuthManager.getConnectionProfiles().first()
verify { mockEditor.putString(eq("connection_profiles"), any()) } assertEquals("10.0.0.99", updated.host)
verify(exactly = 0) { mockEditor.putString("host", any()) }
} }
@Test @Test
fun testSetPort_updatesProfileWhenSelected() { fun testSetPort_updatesProfileWhenSelected() {
val profileJson = """[{"id":"prof-1","name":"Home","host":"10.0.0.1","port":9119}]""" val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119)
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" AuthManager.saveConnectionProfiles(listOf(profile))
every { mockPrefs.getString("connection_profiles", null) } returns profileJson AuthManager.setSelectedProfileId("prof-1")
AuthManager.setPort(9999) AuthManager.setPort(9999)
verify { mockEditor.putString(eq("connection_profiles"), any()) } val updated = AuthManager.getConnectionProfiles().first()
verify(exactly = 0) { mockEditor.putInt("port", any()) } assertEquals(9999, updated.port)
} }
@Test @Test
fun testProfileTokenRoundTrip() { fun testProfileTokenRoundTrip() {
every { mockPrefs.getString("selected_profile_id", null) } returns null AuthManager.setSelectedProfileId(null)
AuthManager.setProfileToken("prof-a", "token-a") AuthManager.setProfileToken("prof-a", "token-a")
verify { mockEditor.putString("token_prof-a", "token-a") } verify { mockEditor.putString("token_prof-a", "token-a") }
@@ -270,104 +307,98 @@ class AuthManagerTest {
fun testConnectionProfiles_roundTrip() { fun testConnectionProfiles_roundTrip() {
val profiles = val profiles =
listOf( listOf(
com.m57.hermescontrol.data.model ConnectionProfile("a", "A", "10.0.0.1", 9119),
.ConnectionProfile("a", "A", "10.0.0.1", 9119), ConnectionProfile("b", "B", "10.0.0.2", 9220),
com.m57.hermescontrol.data.model
.ConnectionProfile("b", "B", "10.0.0.2", 9220),
) )
AuthManager.saveConnectionProfiles(profiles) 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() val loaded = AuthManager.getConnectionProfiles()
assertEquals(2, loaded.size) assertEquals(2, loaded.size)
assertEquals("A", loaded[0].name) assertEquals("A", loaded[0].name)
assertEquals("10.0.0.2", loaded[1].host) 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-11: Multi-profile token scenarios ─────────────────────────
@Test @Test
fun testGetToken_switchesTokenWhenProfileChanges() { 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 // 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" every { mockPrefs.getString("token_prof-a", null) } returns "token-for-a"
AuthManager.setSelectedProfileId("prof-a")
assertEquals("token-for-a", AuthManager.getToken()) assertEquals("token-for-a", AuthManager.getToken())
// Switch to Profile B → returns token_b // 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" every { mockPrefs.getString("token_prof-b", null) } returns "token-for-b"
AuthManager.setSelectedProfileId("prof-b") // Required to clear token cache AuthManager.setSelectedProfileId("prof-b") // Required to clear token cache
assertEquals("token-for-b", AuthManager.getToken()) assertEquals("token-for-b", AuthManager.getToken())
// Switch back to Profile A → still returns token_a // 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 AuthManager.setSelectedProfileId("prof-a") // Required to clear token cache
assertEquals("token-for-a", AuthManager.getToken()) assertEquals("token-for-a", AuthManager.getToken())
} }
@Test @Test
fun testGetToken_fallsBackToGlobalWhenSelectedProfileLacksToken() { 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 // 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("token_prof-a", null) } returns null
every { mockPrefs.getString("auth_token", null) } returns "shared-global-token" every { mockPrefs.getString("auth_token", null) } returns "shared-global-token"
AuthManager.setSelectedProfileId("prof-a")
assertEquals("shared-global-token", AuthManager.getToken()) assertEquals("shared-global-token", AuthManager.getToken())
// Switch to Profile B, which also has no token → same global fallback // 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 every { mockPrefs.getString("token_prof-b", null) } returns null
AuthManager.setSelectedProfileId("prof-b")
assertEquals("shared-global-token", AuthManager.getToken()) assertEquals("shared-global-token", AuthManager.getToken())
} }
@Test @Test
fun testGetToken_selectiveProfileTokens_someNull() { 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 // 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" every { mockPrefs.getString("token_prof-a", null) } returns "token-a"
AuthManager.setSelectedProfileId("prof-a")
assertEquals("token-a", AuthManager.getToken()) assertEquals("token-a", AuthManager.getToken())
// Profile B has no token, but global exists // 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("token_prof-b", null) } returns null
every { mockPrefs.getString("auth_token", null) } returns "fallback" 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()) assertEquals("fallback", AuthManager.getToken())
// Profile C has its own token too // 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" 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()) assertEquals("token-c", AuthManager.getToken())
} }
@Test @Test
fun testSetToken_switchingProfiles_maintainsSeparateTokens() { 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 // 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") AuthManager.setToken("set-while-on-profile-a")
verify { mockEditor.putString("token_prof-a", "set-while-on-profile-a") } verify { mockEditor.putString("token_prof-a", "set-while-on-profile-a") }
verify { mockEditor.apply() } verify { mockEditor.apply() }
// Switch to Profile B // 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 // Set a different token for Profile B
AuthManager.setToken("set-while-on-profile-b") AuthManager.setToken("set-while-on-profile-b")
@@ -377,7 +408,10 @@ class AuthManagerTest {
@Test @Test
fun testSetToken_toNull_clearsProfileToken() { 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) AuthManager.setToken(null)
@@ -387,10 +421,10 @@ class AuthManagerTest {
@Test @Test
fun testGetSelectedProfileId_returnsNullForEmptyString() { fun testGetSelectedProfileId_returnsNullForEmptyString() {
every { mockPrefs.getString("selected_profile_id", null) } returns "" AuthManager.setSelectedProfileId("")
assertNull(AuthManager.getSelectedProfileId()) assertNull(AuthManager.getSelectedProfileId())
every { mockPrefs.getString("selected_profile_id", null) } returns " " AuthManager.setSelectedProfileId(" ")
assertNull(AuthManager.getSelectedProfileId()) assertNull(AuthManager.getSelectedProfileId())
} }
} }
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.connect
import android.app.Application import android.app.Application
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
@@ -388,7 +389,7 @@ class ConnectViewModelTest {
@Test @Test
fun testSelectProfile_updatesState() { fun testSelectProfile_updatesState() {
val profile = val profile =
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
id = "test-id", id = "test-id",
name = "Test Profile", name = "Test Profile",
host = "192.168.1.100", host = "192.168.1.100",
@@ -463,7 +464,7 @@ class ConnectViewModelTest {
@Test @Test
fun testLoadSavedValues_withProfiles_loadsSelected() { fun testLoadSavedValues_withProfiles_loadsSelected() {
val profile = val profile =
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
id = "prof-1", id = "prof-1",
name = "Work", name = "Work",
host = "10.0.0.1", host = "10.0.0.1",
@@ -488,7 +489,7 @@ class ConnectViewModelTest {
@Test @Test
fun testLoadSavedValues_withStaleSelectedId_fallsBackToDefaults() { fun testLoadSavedValues_withStaleSelectedId_fallsBackToDefaults() {
val profile = val profile =
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
id = "prof-1", id = "prof-1",
name = "Work", name = "Work",
host = "10.0.0.1", host = "10.0.0.1",
@@ -510,7 +511,7 @@ class ConnectViewModelTest {
every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.getProfileToken(any()) } returns null
val profile = val profile =
com.m57.hermescontrol.data.model.ConnectionProfile( ConnectionProfile(
id = "prof-2", id = "prof-2",
name = "NoToken", name = "NoToken",
host = "10.0.0.2", host = "10.0.0.2",
@@ -574,10 +575,8 @@ class ConnectViewModelTest {
fun testMultipleProfiles_loadedOnInit() { fun testMultipleProfiles_loadedOnInit() {
val profiles = val profiles =
listOf( listOf(
com.m57.hermescontrol.data.model ConnectionProfile("a", "Alpha", "10.0.0.1", 9119),
.ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), ConnectionProfile("b", "Beta", "10.0.0.2", 9220),
com.m57.hermescontrol.data.model
.ConnectionProfile("b", "Beta", "10.0.0.2", 9220),
) )
every { AuthManager.getConnectionProfiles() } returns profiles every { AuthManager.getConnectionProfiles() } returns profiles
every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.getSelectedProfileId() } returns null
@@ -1,7 +1,7 @@
package com.m57.hermescontrol.ui.settings 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.local.AuthManager
import com.m57.hermescontrol.data.model.ConnectionProfile
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
@@ -218,4 +218,25 @@ class SettingsViewModelTest {
verify(exactly = 0) { AuthManager.setSelectedProfileId(null) } verify(exactly = 0) { AuthManager.setSelectedProfileId(null) }
verify { ApiClient.rebuild() } 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)
}
} }
+2
View File
@@ -27,6 +27,7 @@ turbine = "1.2.0"
coil = "2.7.0" coil = "2.7.0"
room = "2.7.1" room = "2.7.1"
sqlcipher = "4.16.0" sqlcipher = "4.16.0"
datastore = "1.1.2"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } 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 # Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
# Database (Room) # Database (Room)
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }