diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 6a6265d9..7624f819 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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>(emptyList()) val bottomNavItemsFlow: StateFlow> = _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 { + val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList() + return try { + val type = + object : com.google.gson.reflect.TypeToken< + List, + >() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + fun saveConnectionProfiles(profiles: List) { + 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 ─────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt new file mode 100644 index 00000000..73732fd8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionProfile.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index 1e76e00a..ce62929b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index c0d474b0..a7c65ef2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -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 = emptyList(), + val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null, ) class ConnectViewModel : ViewModel() { @@ -29,19 +33,63 @@ class ConnectViewModel : ViewModel() { val uiState: StateFlow = _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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 1e820e97..4696497a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index b887c4a4..e08f0ae1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -27,6 +27,9 @@ data class SettingsUiState( val selectedNavItems: List = emptyList(), val typingEffectEnabled: Boolean = false, val typingEffectDelayMs: Int = 30, + val profiles: List = 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) } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 8c9d7d5b..c77fe129 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -78,6 +78,12 @@ class E2eIntegrationTest { every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setPort(any()) } returns Unit + every { AuthManager.getConnectionProfiles() } returns emptyList() + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.setSelectedProfileId(any()) } returns Unit + every { AuthManager.getProfileToken(any()) } returns null + every { AuthManager.setProfileToken(any(), any()) } returns Unit + every { AuthManager.saveConnectionProfiles(any()) } returns Unit } @After diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 2e07a7be..3c4e4093 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -49,6 +49,12 @@ class ConnectViewModelTest { every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setPort(any()) } returns Unit + every { AuthManager.getConnectionProfiles() } returns emptyList() + every { AuthManager.saveConnectionProfiles(any()) } returns Unit + every { AuthManager.getProfileToken(any()) } returns null + every { AuthManager.setProfileToken(any(), any()) } returns Unit + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.setSelectedProfileId(any()) } returns Unit } @After @@ -322,4 +328,77 @@ class ConnectViewModelTest { assertEquals(validToken, state.token) assertNull(state.errorMessage) } + + @Test + fun testSelectProfile_updatesState() { + val profile = + com.m57.hermescontrol.data.model.ConnectionProfile( + id = "test-id", + name = "Test Profile", + host = "192.168.1.100", + port = 8080, + ) + every { AuthManager.getProfileToken("test-id") } returns "profile-token" + + val viewModel = ConnectViewModel() + viewModel.selectProfile(profile) + + val state = viewModel.uiState.value + assertEquals("Test Profile", state.profileName) + assertEquals("192.168.1.100", state.host) + assertEquals("8080", state.port) + assertEquals("profile-token", state.token) + assertEquals(profile, state.selectedProfile) + + verify { AuthManager.setSelectedProfileId("test-id") } + } + + @Test + fun testSelectProfile_null_clearsState() { + val viewModel = ConnectViewModel() + viewModel.selectProfile(null) + + val state = viewModel.uiState.value + assertEquals("", state.profileName) + assertEquals("127.0.0.1", state.host) + assertEquals("9119", state.port) + assertEquals("", state.token) + assertNull(state.selectedProfile) + + verify { AuthManager.setSelectedProfileId(null) } + } + + @Test + fun testConnect_saveProfile_savesAndSelects() = + runTest { + val viewModel = ConnectViewModel() + viewModel.onTokenChange("valid-token") + viewModel.onHostChange("127.0.0.1") + viewModel.onPortChange("9119") + viewModel.onProfileNameChange("New Profile") + viewModel.onSaveProfileChange(true) + + val mockResponse = mockk>() + every { mockResponse.isSuccessful } returns true + every { mockResponse.body() } returns + StatusResponse( + version = "1.0", + gateway_running = true, + active_sessions = 1, + auth_required = false, + gateway_platforms = emptyMap(), + ) + coEvery { mockApiService.getStatus() } returns mockResponse + + viewModel.connect() + advanceUntilIdle() + + verify { AuthManager.saveConnectionProfiles(any()) } + verify { AuthManager.setSelectedProfileId(any()) } + verify { AuthManager.setProfileToken(any(), "valid-token") } + verify { ApiClient.rebuild() } + + val state = viewModel.uiState.value + assertTrue(state.connectionSuccess) + } }