* 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:
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,
|
||||
)
|
||||
Reference in new issue
Block a user