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_BOTTOM_NAV_ITEMS = "bottom_nav_items"
private const val KEY_TYPING_EFFECT_ENABLED = "typing_effect_enabled" 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_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_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119 private const val DEFAULT_PORT = 9119
@@ -39,6 +41,8 @@ object AuthManager {
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList()) private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow() val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
private val gson = com.google.gson.Gson()
/** /**
* Initialise the encrypted preferences. * Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate(). * Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -71,28 +75,112 @@ object AuthManager {
"AuthManager not initialised – call AuthManager.init(context) first", "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 ──────────────────────────────────────────────────────────── // ── 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?) { 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 ───────────────────────────────────────────────────────────── // ── 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) { 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 ───────────────────────────────────────────────────────────── // ── 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) { 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 ─────────────────────────────────────────────────── // ── 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -118,6 +123,48 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp)) 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) // Pairing (primary path)
Column( Column(
modifier = Modifier.fillMaxWidth(), 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( Button(
onClick = viewModel::connect, onClick = viewModel::connect,
enabled = !state.isConnecting, enabled = !state.isConnecting,
@@ -22,6 +22,10 @@ data class ConnectUiState(
val isConnecting: Boolean = false, val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false, val connectionSuccess: Boolean = false,
val errorMessage: String? = null, 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() { class ConnectViewModel : ViewModel() {
@@ -29,19 +33,63 @@ class ConnectViewModel : ViewModel() {
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow() val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
init { init {
// Load saved values loadSavedValues()
}
fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: "" val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost() val savedHost = AuthManager.getHost()
val savedPort = AuthManager.getPort() val savedPort = AuthManager.getPort()
val profiles = AuthManager.getConnectionProfiles()
val selectedId = AuthManager.getSelectedProfileId()
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
_uiState.update { _uiState.update {
it.copy( it.copy(
token = savedToken, token = savedToken,
host = savedHost, host = savedHost,
port = savedPort.toString(), 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) { fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) } _uiState.update { it.copy(token = value.trim(), errorMessage = null) }
} }
@@ -72,19 +120,54 @@ class ConnectViewModel : ViewModel() {
_uiState.update { it.copy(isConnecting = true, errorMessage = null) } _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 { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { 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() } safeApiCall { ApiClient.hermesApi.getStatus() }
} }
when (result) { when (result) {
is NetworkResult.Success -> { 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 { _uiState.update {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null) it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
} }
@@ -91,6 +91,86 @@ fun SettingsScreen(
) { ) {
// Connection section // Connection section
SectionCard(title = "Connection") { 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( OutlinedTextField(
value = state.host, value = state.host,
onValueChange = viewModel::onHostChange, onValueChange = viewModel::onHostChange,
@@ -27,6 +27,9 @@ data class SettingsUiState(
val selectedNavItems: List<String> = emptyList(), val selectedNavItems: List<String> = emptyList(),
val typingEffectEnabled: Boolean = false, val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
) )
class SettingsViewModel : ViewModel() { class SettingsViewModel : ViewModel() {
@@ -38,6 +41,7 @@ class SettingsViewModel : ViewModel() {
} }
private fun loadSettings() { private fun loadSettings() {
val selectedId = AuthManager.getSelectedProfileId()
_uiState.update { _uiState.update {
it.copy( it.copy(
host = AuthManager.getHost(), host = AuthManager.getHost(),
@@ -51,10 +55,50 @@ class SettingsViewModel : ViewModel() {
selectedNavItems = AuthManager.getBottomNavItems(), selectedNavItems = AuthManager.getBottomNavItems(),
typingEffectEnabled = AuthManager.isTypingEffectEnabled(), typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), 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) { fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) } _uiState.update { it.copy(host = value.trim(), isSaved = false) }
} }
@@ -145,6 +189,7 @@ class SettingsViewModel : ViewModel() {
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs) AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
ApiClient.rebuild() ApiClient.rebuild()
loadSettings()
_uiState.update { it.copy(isSaved = true, testResult = null) } _uiState.update { it.copy(isSaved = true, testResult = null) }
} }
@@ -78,6 +78,12 @@ class E2eIntegrationTest {
every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(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 @After
@@ -49,6 +49,12 @@ class ConnectViewModelTest {
every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(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 @After
@@ -322,4 +328,77 @@ class ConnectViewModelTest {
assertEquals(validToken, state.token) assertEquals(validToken, state.token)
assertNull(state.errorMessage) 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<Response<StatusResponse>>()
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)
}
} }