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

@@ -0,0 +1,12 @@
package com.m57.hermescontrol.data.config
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class ConnectionProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int,
)
@@ -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,
)