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

@@ -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) {