* 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
@@ -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)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user