feat(connect): saved connection profiles support (#107) (#191)

* feat(#107): implement saved connection profiles support

* fix(connect): fallback profiles resolution and stubbing in tests
This commit is contained in:
M57 authored and GitHub committed 2026-06-21 00:18:35 +03:00
1 parent 0eed12d18e
commit 00c6e75f50
8 files changed
+479 -13

No files matched your search

@@ -26,6 +26,8 @@ object AuthManager {
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 DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -39,6 +41,8 @@ object AuthManager {
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
private val gson = com.google.gson.Gson()
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -71,28 +75,112 @@ object AuthManager {
"AuthManager not initialised – call AuthManager.init(context) first",
)
// ── Connection Profiles ──────────────────────────────────────────────
fun getConnectionProfiles(): List<com.m57.hermescontrol.data.model.ConnectionProfile> {
val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList()
return try {
val type =
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.ConnectionProfile>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
fun saveConnectionProfiles(profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile>) {
val json = gson.toJson(profiles)
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
}
fun getProfileToken(profileId: String): String? {
return requirePrefs().getString("token_$profileId", null)
}
fun setProfileToken(
profileId: String,
token: String?,
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
}
fun getSelectedProfileId(): String? {
val id = requirePrefs().getString(KEY_SELECTED_PROFILE_ID, null)
return if (id.isNullOrBlank()) null else id
}
fun setSelectedProfileId(id: String?) {
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply()
}
// ── Token ────────────────────────────────────────────────────────────
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
fun getToken(): String? {
val selectedId = getSelectedProfileId()
if (selectedId != null) {
val token = getProfileToken(selectedId)
if (token != null) return token
}
return requirePrefs().getString(KEY_TOKEN, null)
}
fun setToken(token: String?) {
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
val selectedId = getSelectedProfileId()
if (selectedId != null) {
setProfileToken(selectedId, token)
} else {
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
}
}
// ── Host ─────────────────────────────────────────────────────────────
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_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 setHost(host: String) {
requirePrefs().edit().putString(KEY_HOST, host).apply()
val selectedId = getSelectedProfileId()
if (selectedId != null) {
val profiles =
getConnectionProfiles().map {
if (it.id == selectedId) it.copy(host = host) else it
}
saveConnectionProfiles(profiles)
} else {
requirePrefs().edit().putString(KEY_HOST, host).apply()
}
}
// ── Port ─────────────────────────────────────────────────────────────
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_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 setPort(port: Int) {
requirePrefs().edit().putInt(KEY_PORT, port).apply()
val selectedId = getSelectedProfileId()
if (selectedId != null) {
val profiles =
getConnectionProfiles().map {
if (it.id == selectedId) it.copy(port = port) else it
}
saveConnectionProfiles(profiles)
} else {
requirePrefs().edit().putInt(KEY_PORT, port).apply()
}
}
// ── Auto-reconnect ───────────────────────────────────────────────────
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.model
import java.util.UUID
data class ConnectionProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int,
)
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -23,11 +24,15 @@ 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.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -118,6 +123,48 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp))
// Connection Profiles Dropdown / Selection
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { profilesExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text =
if (state.selectedProfile != null) {
"Profile: ${state.selectedProfile!!.name}"
} else {
"Select Saved Profile"
},
)
}
DropdownMenu(
expanded = profilesExpanded,
onDismissRequest = { profilesExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
DropdownMenuItem(
text = { Text("None (Standalone)") },
onClick = {
viewModel.selectProfile(null)
profilesExpanded = false
},
)
state.profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(profile)
profilesExpanded = false
},
)
}
}
}
}
// Pairing (primary path)
Column(
modifier = Modifier.fillMaxWidth(),
@@ -157,6 +204,34 @@ fun ConnectScreen(
}
}
// Profile Save Options prior to connecting
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = state.saveProfile,
onCheckedChange = viewModel::onSaveProfileChange,
)
Text(
text = "Save connection profile",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
AnimatedVisibility(visible = state.saveProfile) {
OutlinedTextField(
value = state.profileName,
onValueChange = viewModel::onProfileNameChange,
label = { Text("Profile Name") },
placeholder = { Text("e.g. Local Hermit, Remote Server") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
@@ -22,6 +22,10 @@ data class ConnectUiState(
val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false,
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,
)
class ConnectViewModel : ViewModel() {
@@ -29,19 +33,63 @@ class ConnectViewModel : ViewModel() {
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
init {
// Load saved values
loadSavedValues()
}
fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
val savedPort = AuthManager.getPort()
val profiles = AuthManager.getConnectionProfiles()
val selectedId = AuthManager.getSelectedProfileId()
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
_uiState.update {
it.copy(
token = savedToken,
host = savedHost,
port = savedPort.toString(),
profiles = profiles,
selectedProfile = selectedProfile,
profileName = selectedProfile?.name ?: "",
)
}
}
fun onProfileNameChange(value: String) {
_uiState.update { it.copy(profileName = value, errorMessage = null) }
}
fun onSaveProfileChange(value: Boolean) {
_uiState.update { it.copy(saveProfile = value) }
}
fun selectProfile(profile: com.m57.hermescontrol.data.model.ConnectionProfile?) {
if (profile == null) {
AuthManager.setSelectedProfileId(null)
_uiState.update {
it.copy(
selectedProfile = null,
profileName = "",
token = "",
host = "127.0.0.1",
port = "9119",
)
}
} else {
AuthManager.setSelectedProfileId(profile.id)
val token = AuthManager.getProfileToken(profile.id) ?: ""
_uiState.update {
it.copy(
selectedProfile = profile,
profileName = profile.name,
host = profile.host,
port = profile.port.toString(),
token = token,
)
}
}
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
}
@@ -72,19 +120,54 @@ class ConnectViewModel : ViewModel() {
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
// Save settings before testing so ApiClient uses the right base URL
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
// Update ApiClient temporarily with current inputs to verify connection
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
if (state.saveProfile) {
val currentProfiles = AuthManager.getConnectionProfiles()
val existingIndex =
currentProfiles.indexOfFirst {
it.name.equals(
state.profileName,
ignoreCase = true,
)
}
val targetProfile =
if (existingIndex >= 0) {
currentProfiles[existingIndex].copy(host = state.host, port = port)
} else {
com.m57.hermescontrol.data.model.ConnectionProfile(
name = state.profileName,
host = state.host,
port = port,
)
}
val updatedProfiles =
if (existingIndex >= 0) {
currentProfiles.mapIndexed { idx, p -> if (idx == existingIndex) targetProfile else p }
} else {
currentProfiles + targetProfile
}
AuthManager.saveConnectionProfiles(updatedProfiles)
AuthManager.setSelectedProfileId(targetProfile.id)
AuthManager.setProfileToken(targetProfile.id, state.token)
} else {
// Clear selected profile if we connected standalone and didn't check "save profile"
AuthManager.setSelectedProfileId(null)
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
}
ApiClient.rebuild()
_uiState.update {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
}
@@ -91,6 +91,86 @@ fun SettingsScreen(
) {
// Connection section
SectionCard(title = "Connection") {
// Connection Profiles management in Settings
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Text(
text = "Connection Profile",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
OutlinedButton(
onClick = { profilesExpanded = true },
modifier = Modifier.fillMaxWidth(),
) {
val activeProfile = state.profiles.firstOrNull { it.id == state.selectedProfileId }
Text(text = activeProfile?.name ?: "Standalone / Default")
}
DropdownMenu(
expanded = profilesExpanded,
onDismissRequest = { profilesExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
DropdownMenuItem(
text = { Text("None (Standalone / Default)") },
onClick = {
viewModel.selectProfile(null)
profilesExpanded = false
},
)
state.profiles.forEach { profile ->
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(profile.name)
IconButton(
onClick = {
viewModel.deleteProfile(profile.id)
profilesExpanded = false
},
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Delete Profile",
tint = MaterialTheme.colorScheme.error,
)
}
}
},
onClick = {
viewModel.selectProfile(profile.id)
profilesExpanded = false
},
)
}
}
}
if (state.selectedProfileId != null) {
OutlinedTextField(
value = state.renameProfileName,
onValueChange = viewModel::onRenameProfileNameChange,
label = { Text("Rename Profile") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Button(
onClick = viewModel::renameProfile,
modifier = Modifier.padding(end = 4.dp),
) {
Text("Rename")
}
},
)
Spacer(modifier = Modifier.height(12.dp))
}
}
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
@@ -27,6 +27,9 @@ 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 selectedProfileId: String? = null,
val renameProfileName: String = "",
)
class SettingsViewModel : ViewModel() {
@@ -38,6 +41,7 @@ class SettingsViewModel : ViewModel() {
}
private fun loadSettings() {
val selectedId = AuthManager.getSelectedProfileId()
_uiState.update {
it.copy(
host = AuthManager.getHost(),
@@ -51,10 +55,50 @@ class SettingsViewModel : ViewModel() {
selectedNavItems = AuthManager.getBottomNavItems(),
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
profiles = AuthManager.getConnectionProfiles(),
selectedProfileId = selectedId,
renameProfileName =
AuthManager.getConnectionProfiles().firstOrNull {
p ->
p.id == selectedId
}?.name ?: "",
)
}
}
fun selectProfile(profileId: String?) {
AuthManager.setSelectedProfileId(profileId)
loadSettings()
ApiClient.rebuild()
}
fun onRenameProfileNameChange(value: String) {
_uiState.update { it.copy(renameProfileName = value, isSaved = false) }
}
fun renameProfile() {
val currentId = _uiState.value.selectedProfileId ?: return
val newName = _uiState.value.renameProfileName.trim()
if (newName.isBlank()) return
val updatedProfiles =
AuthManager.getConnectionProfiles().map {
if (it.id == currentId) it.copy(name = newName) else it
}
AuthManager.saveConnectionProfiles(updatedProfiles)
loadSettings()
}
fun deleteProfile(profileId: String) {
val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId }
AuthManager.saveConnectionProfiles(updatedProfiles)
AuthManager.setProfileToken(profileId, null)
if (AuthManager.getSelectedProfileId() == profileId) {
AuthManager.setSelectedProfileId(null)
}
loadSettings()
ApiClient.rebuild()
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
}
@@ -145,6 +189,7 @@ class SettingsViewModel : ViewModel() {
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
ApiClient.rebuild()
loadSettings()
_uiState.update { it.copy(isSaved = true, testResult = null) }
}