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 authored and GitHub committed 2026-07-06 22:40:44 +03:00
1 parent 5697405b19
commit 430a083647
20 files changed
+774 -288

No files matched your search

+1
View File
@@ -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
@@ -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)
},
)
},
)
}
@@ -1,4 +1,5 @@
package com.m57.hermescontrol.data.model
package com.m57.hermescontrol.data.config
import kotlinx.serialization.Serializable
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 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<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())
val bottomNavItemsFlow: StateFlow<List<String>> = _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<ConnectionProfile> {
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 getConnectionProfiles(): List<ConnectionProfile> = serverStore.getLatestState().connectionProfiles
fun saveConnectionProfiles(profiles: List<ConnectionProfile>) {
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<PinnedModel> {
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 getPinnedModels(): List<PinnedModel> = serverStore.getLatestState().pinnedModels
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) {
val json =
com.m57.hermescontrol.data.remote.OkHttpProvider.json
.encodeToString(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
fun savePinnedModels(pinned: List<PinnedModel>) {
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<String> = getBottomNavItemsInternal(requirePrefs())
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() }
}
fun getBottomNavItems(): List<String> = serverStore.getLatestState().bottomNavItems
/** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */
fun setBottomNavItems(items: List<String>) {
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) }
}
}
@@ -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 }
@@ -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,
@@ -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<ConnectionProfile> = 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<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 {
private const val TAG = "AuthLoginVM"
}
@@ -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<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
val profiles: List<ConnectionProfile> = 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,
@@ -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 = {
@@ -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<String> = emptyList(),
val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30,
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
val profiles: List<ConnectionProfile> = 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) {
+2
View File
@@ -781,6 +781,8 @@
<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_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 -->
<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 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<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
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())
}
}
@@ -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
@@ -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)
}
}
+2
View File
@@ -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" }