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 a14a3ec1..72adf64e 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.connect +import android.app.Application import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -45,6 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -60,7 +62,14 @@ import com.m57.hermescontrol.R fun ConnectScreen( onConnected: () -> Unit, modifier: Modifier = Modifier, - viewModel: ConnectViewModel = viewModel { ConnectViewModel() }, + viewModel: ConnectViewModel = + viewModel( + factory = + run { + val app = LocalContext.current.applicationContext as Application + ConnectViewModelFactory(app) + }, + ), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -135,11 +144,9 @@ fun ConnectScreen( ) { Text( text = - if (state.selectedProfile != null) { - stringResource(R.string.connect_selected_profile, state.selectedProfile!!.name) - } else { - stringResource(R.string.connect_action_select_profile) - }, + state.selectedProfile?.let { p -> + stringResource(R.string.connect_selected_profile, p.name) + } ?: stringResource(R.string.connect_action_select_profile), ) } DropdownMenu( 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 d5ddbceb..63d68967 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 @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.connect +import android.app.Application import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkError @@ -28,7 +31,7 @@ data class ConnectUiState( val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null, ) -class ConnectViewModel : ViewModel() { +class ConnectViewModel(private val app: Application) : ViewModel() { private val _uiState = MutableStateFlow(ConnectUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -110,16 +113,16 @@ class ConnectViewModel : ViewModel() { fun connect() { val state = _uiState.value if (state.token.isBlank()) { - _uiState.update { it.copy(errorMessage = "Token is required") } + _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) } return } if (state.host.isBlank()) { - _uiState.update { it.copy(errorMessage = "Host is required") } + _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) } return } val port = state.port.toIntOrNull() if (port == null || port !in 1..65535) { - _uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") } + _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) } return } @@ -185,15 +188,15 @@ class ConnectViewModel : ViewModel() { when (err.code) { 401 -> { AuthManager.setToken(null) - "Invalid token (401 Unauthorized)" + app.getString(R.string.connect_error_401) } 403 -> { - "Access denied (403 Forbidden)" + app.getString(R.string.connect_error_403) } else -> { - "Server returned HTTP ${err.code}" + String.format(app.getString(R.string.connect_error_http_code), err.code) } } } @@ -201,20 +204,46 @@ class ConnectViewModel : ViewModel() { is NetworkError.Connection -> { val causeMessage = err.cause.message ?: "" when { - causeMessage.contains("timeout", true) -> "Connection timed out" - causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?" - causeMessage.contains("resolve", true) -> "Could not resolve host" - else -> "Connection failed: ${err.cause.message}" + causeMessage.contains( + "timeout", + true, + ) -> app.getString(R.string.connect_error_timeout) + causeMessage.contains( + "refused", + true, + ) -> app.getString(R.string.connect_error_refused) + causeMessage.contains( + "resolve", + true, + ) -> app.getString(R.string.connect_error_resolve) + else -> + String.format( + app.getString(R.string.connect_error_connection_failed), + err.cause.message ?: "", + ) } } is NetworkError.Unknown -> { val causeMessage = err.cause.message ?: "" when { - causeMessage.contains("timeout", true) -> "Connection timed out" - causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?" - causeMessage.contains("resolve", true) -> "Could not resolve host" - else -> "Connection failed: ${err.cause.message}" + causeMessage.contains( + "timeout", + true, + ) -> app.getString(R.string.connect_error_timeout) + causeMessage.contains( + "refused", + true, + ) -> app.getString(R.string.connect_error_refused) + causeMessage.contains( + "resolve", + true, + ) -> app.getString(R.string.connect_error_resolve) + else -> + String.format( + app.getString(R.string.connect_error_connection_failed), + err.cause.message ?: "", + ) } } } @@ -275,7 +304,7 @@ class ConnectViewModel : ViewModel() { // Decoded valid JSON but missing required fields _uiState.update { it.copy( - errorMessage = "Malformed pairing string — missing host, port, or token", + errorMessage = app.getString(R.string.connect_error_missing_fields), ) } return @@ -283,7 +312,7 @@ class ConnectViewModel : ViewModel() { // Decoded to valid string but not JSON shape — not a pairing string _uiState.update { it.copy( - errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON", + errorMessage = app.getString(R.string.connect_error_malformed), ) } return @@ -294,17 +323,38 @@ class ConnectViewModel : ViewModel() { } else { _uiState.update { it.copy( - errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON", + errorMessage = app.getString(R.string.connect_error_malformed), ) } } return } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") } + _uiState.update { + it.copy( + errorMessage = + String.format( + app.getString(R.string.connect_error_parse_failed), + e.message ?: "", + ), + ) + } return } } catch (e: Exception) { - _uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") } + _uiState.update { + it.copy( + errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""), + ) + } } } } + +class ConnectViewModelFactory( + private val app: Application, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ConnectViewModel(app) as T + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4d16e12..2e2e729b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,6 +165,22 @@ Profile: %1$s Select Saved Profile None (Standalone) + + + Token is required + Host is required + Port must be between 1 and 65535 + Invalid token (401 Unauthorized) + Access denied (403 Forbidden) + Server returned HTTP %1$d + Connection timed out + Connection refused – is Hermes running? + Could not resolve host + Connection failed: %1$s + Malformed pairing string — missing host, port, or token + Malformed pairing string — expected URL or Base64-encoded JSON + Failed to parse pairing string: %1$s + Pairing string Paste hermes://\u2026 or Base64 config Save connection profile diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 01d612fb..d6cdc25e 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol +import android.app.Application import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.m57.hermescontrol.data.local.AuthManager @@ -56,6 +57,7 @@ import java.io.IOException class E2eIntegrationTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApiService: HermesApiService + private val mockApp = mockk(relaxed = true) @Before fun setUp() { @@ -85,6 +87,9 @@ class E2eIntegrationTest { every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit + + // Application stubs + every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)" } @After @@ -760,7 +765,7 @@ class E2eIntegrationTest { val mockResponse = createErrorResponse(401) coEvery { mockApiService.getStatus() } returns mockResponse - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("expired-token") viewModel.connect() advanceUntilIdle() @@ -781,7 +786,7 @@ class E2eIntegrationTest { val statusResponse = mockk() coEvery { mockApiService.getStatus() } returns Response.success(statusResponse) - val connectViewModel = ConnectViewModel() + val connectViewModel = ConnectViewModel(mockApp) connectViewModel.onTokenChange("valid-token") connectViewModel.onHostChange("127.0.0.1") connectViewModel.onPortChange("9119") @@ -1069,7 +1074,7 @@ class E2eIntegrationTest { val statusResponse = mockk() coEvery { mockApiService.getStatus() } returns Response.success(statusResponse) - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") advanceUntilIdle() @@ -1095,7 +1100,7 @@ class E2eIntegrationTest { val statusResponse = mockk() coEvery { mockApiService.getStatus() } returns Response.success(statusResponse) - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onPairingString(base64Str) advanceUntilIdle() 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 df691aba..1c626649 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 @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.connect +import android.app.Application +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -25,6 +27,7 @@ import retrofit2.Response class ConnectViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApiService: HermesApiService + private val mockApp = mockk(relaxed = true) @Before fun setUp() { @@ -60,6 +63,24 @@ class ConnectViewModelTest { every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.setSelectedProfileId(any()) } returns Unit + + // Mock Application string resources + every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required" + every { mockApp.getString(R.string.connect_error_host_required) } returns "Host is required" + every { mockApp.getString(R.string.connect_error_port_invalid) } returns "Port must be between 1 and 65535" + every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)" + every { mockApp.getString(R.string.connect_error_403) } returns "Access denied (403 Forbidden)" + every { mockApp.getString(R.string.connect_error_http_code) } returns "Server returned HTTP %1\$d" + every { mockApp.getString(R.string.connect_error_refused) } returns "Connection refused – is Hermes running?" + every { mockApp.getString(R.string.connect_error_timeout) } returns "Connection timed out" + every { mockApp.getString(R.string.connect_error_connection_failed) } returns "Connection failed: %1\$s" + every { + mockApp.getString(R.string.connect_error_malformed) + } returns "Malformed pairing string — expected URL or Base64-encoded JSON" + every { + mockApp.getString(R.string.connect_error_missing_fields) + } returns "Malformed pairing string — missing host, port, or token" + every { mockApp.getString(R.string.connect_error_parse_failed) } returns "Failed to parse pairing string: %1\$s" } @After @@ -74,7 +95,7 @@ class ConnectViewModelTest { every { AuthManager.getHost() } returns "hermes.local" every { AuthManager.getPort() } returns 8888 - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) val state = viewModel.uiState.value assertEquals("saved-token", state.token) @@ -87,7 +108,7 @@ class ConnectViewModelTest { @Test fun testOnTokenChange() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange(" new-token ") assertEquals("new-token", viewModel.uiState.value.token) assertNull(viewModel.uiState.value.errorMessage) @@ -95,7 +116,7 @@ class ConnectViewModelTest { @Test fun testOnHostChange() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onHostChange(" 192.168.1.50 ") assertEquals("192.168.1.50", viewModel.uiState.value.host) assertNull(viewModel.uiState.value.errorMessage) @@ -103,7 +124,7 @@ class ConnectViewModelTest { @Test fun testOnPortChange() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onPortChange("90abc90") assertEquals("9090", viewModel.uiState.value.port) assertNull(viewModel.uiState.value.errorMessage) @@ -111,7 +132,7 @@ class ConnectViewModelTest { @Test fun testConnect_blankToken_showsError() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("") viewModel.connect() assertEquals("Token is required", viewModel.uiState.value.errorMessage) @@ -119,7 +140,7 @@ class ConnectViewModelTest { @Test fun testConnect_blankHost_showsError() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("valid-token") viewModel.onHostChange("") viewModel.connect() @@ -128,7 +149,7 @@ class ConnectViewModelTest { @Test fun testConnect_invalidPortBounds_showsError() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("valid-token") viewModel.onHostChange("127.0.0.1") @@ -148,7 +169,7 @@ class ConnectViewModelTest { @Test fun testConnect_success() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("valid-token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -185,7 +206,7 @@ class ConnectViewModelTest { @Test fun testConnect_failure_unauthorized() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("invalid-token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -207,7 +228,7 @@ class ConnectViewModelTest { @Test fun testConnect_failure_forbidden() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("invalid-token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -229,7 +250,7 @@ class ConnectViewModelTest { @Test fun testConnect_failure_otherHttpCode() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -251,7 +272,7 @@ class ConnectViewModelTest { @Test fun testConnect_exception_connectionRefused() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -270,7 +291,7 @@ class ConnectViewModelTest { @Test fun testConnect_exception_timeout() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -289,7 +310,7 @@ class ConnectViewModelTest { @Test fun testConnect_exception_unknown() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -307,7 +328,7 @@ class ConnectViewModelTest { @Test fun testOnPairingString_malformedBase64_showsError() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) // Mock Base64.decode to throw IllegalArgumentException for an invalid short string every { android.util.Base64.decode(any(), any()) } throws IllegalArgumentException("bad base64") @@ -320,7 +341,7 @@ class ConnectViewModelTest { @Test fun testOnPairingString_malformedBase64_validRawToken() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) // Mock Base64.decode to throw IllegalArgumentException every { android.util.Base64.decode(any(), any()) } throws IllegalArgumentException("bad base64") @@ -345,7 +366,7 @@ class ConnectViewModelTest { ) every { AuthManager.getProfileToken("test-id") } returns "profile-token" - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.selectProfile(profile) val state = viewModel.uiState.value @@ -360,7 +381,7 @@ class ConnectViewModelTest { @Test fun testSelectProfile_null_clearsState() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.selectProfile(null) val state = viewModel.uiState.value @@ -376,7 +397,7 @@ class ConnectViewModelTest { @Test fun testConnect_saveProfile_savesAndSelects() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("valid-token") viewModel.onHostChange("127.0.0.1") viewModel.onPortChange("9119") @@ -424,7 +445,7 @@ class ConnectViewModelTest { every { AuthManager.getConnectionProfiles() } returns listOf(profile) every { AuthManager.getSelectedProfileId() } returns "prof-1" - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) val state = viewModel.uiState.value assertEquals("Work", state.profileName) @@ -446,7 +467,7 @@ class ConnectViewModelTest { every { AuthManager.getConnectionProfiles() } returns listOf(profile) every { AuthManager.getSelectedProfileId() } returns "nonexistent-id" - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) val state = viewModel.uiState.value assertNull(state.selectedProfile) @@ -465,7 +486,7 @@ class ConnectViewModelTest { host = "10.0.0.2", port = 9220, ) - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.selectProfile(profile) assertEquals("", viewModel.uiState.value.token) @@ -475,7 +496,7 @@ class ConnectViewModelTest { @Test fun testConnect_withoutSaveProfile_clearsSelectedProfile() = runTest { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onTokenChange("standalone-token") viewModel.onHostChange("10.0.0.1") viewModel.onPortChange("9119") @@ -504,14 +525,14 @@ class ConnectViewModelTest { @Test fun testOnProfileNameChange_updatesState() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onProfileNameChange("My Profile") assertEquals("My Profile", viewModel.uiState.value.profileName) } @Test fun testOnSaveProfileChange_togglesFlag() { - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) assertFalse(viewModel.uiState.value.saveProfile) viewModel.onSaveProfileChange(true) assertTrue(viewModel.uiState.value.saveProfile) @@ -529,7 +550,7 @@ class ConnectViewModelTest { every { AuthManager.getConnectionProfiles() } returns profiles every { AuthManager.getSelectedProfileId() } returns null - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) assertEquals(2, viewModel.uiState.value.profiles.size) assertEquals("Alpha", viewModel.uiState.value.profiles[0].name) assertEquals("Beta", viewModel.uiState.value.profiles[1].name) @@ -559,7 +580,7 @@ class ConnectViewModelTest { ) coEvery { mockApiService.getStatus() } returns mockResponse - val viewModel = ConnectViewModel() + val viewModel = ConnectViewModel(mockApp) viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123") val state = viewModel.uiState.value