* feat(#107): implement saved connection profiles support * fix(connect): fallback profiles resolution and stubbing in tests
This commit is contained in:
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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user