From d4328d0fb2a215504bc6eb8aadeece19e62fdcb8 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Tue, 16 Jun 2026 23:13:04 +0300 Subject: [PATCH] feat: add pairing / connection string parsing & verify with unit tests --- .../hermescontrol/ui/connect/ConnectScreen.kt | 21 +++++++ .../ui/connect/ConnectViewModel.kt | 59 +++++++++++++++++++ .../m57/hermescontrol/E2eIntegrationTest.kt | 52 ++++++++++++++++ 3 files changed, 132 insertions(+) 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 c35dc32d..bd6e710e 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 @@ -129,6 +129,27 @@ fun ConnectScreen( ), ) + var pairingString by remember { mutableStateOf("") } + + OutlinedTextField( + value = pairingString, + onValueChange = { + pairingString = it + if (it.isNotBlank()) { + viewModel.onPairingString(it) + } + }, + label = { Text("Pairing Link / Connection String") }, + placeholder = { Text("Paste hermes://... or Base64 config") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.secondary, + cursorColor = MaterialTheme.colorScheme.secondary, + ), + ) + // Token field OutlinedTextField( value = state.token, 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 6aee6c92..0c20ea89 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 @@ -116,4 +116,63 @@ class ConnectViewModel : ViewModel() { } } } + + fun onPairingString(value: String) { + val trimmed = value.trim() + if (trimmed.isBlank()) return + + try { + if (trimmed.startsWith("hermes://connect?", ignoreCase = true)) { + val uri = android.net.Uri.parse(trimmed) + val host = uri.getQueryParameter("host") + val port = uri.getQueryParameter("port") + val token = uri.getQueryParameter("token") + if (host != null && port != null && token != null) { + _uiState.update { + it.copy( + host = host, + port = port, + token = token, + errorMessage = null, + ) + } + connect() + return + } + } + + // Try decoding as Base64 JSON + try { + val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT) + val decodedString = String(decodedBytes, Charsets.UTF_8) + if (decodedString.startsWith("{") && decodedString.endsWith("}")) { + val json = + com.google.gson.JsonParser + .parseString(decodedString) + .asJsonObject + val host = json.get("host")?.asString + val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString + val token = json.get("token")?.asString + if (host != null && port != null && token != null) { + _uiState.update { + it.copy( + host = host, + port = port, + token = token, + errorMessage = null, + ) + } + connect() + return + } + } + } catch (e: Exception) { + // Ignore base64 decode failures, fallback to setting raw token + } + + _uiState.update { it.copy(token = trimmed, errorMessage = null) } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") } + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 1a4484dc..7e40bfb2 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -980,4 +980,56 @@ class E2eIntegrationTest { coVerify { mockApiService.updateProfileModel("default", any()) } assertEquals("Successfully set model to llama3", viewModel.uiState.value.toastMessage) } + + @Test + fun testConnectViewModel_onPairingString_urlFormat() = + runTest { + mockkStatic(android.net.Uri::class) + val mockUri = mockk() + every { android.net.Uri.parse("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") } returns + mockUri + every { mockUri.getQueryParameter("host") } returns "192.168.1.5" + every { mockUri.getQueryParameter("port") } returns "9119" + every { mockUri.getQueryParameter("token") } returns "TEST_TOKEN" + + val statusResponse = mockk() + coEvery { mockApiService.getStatus() } returns Response.success(statusResponse) + + val viewModel = ConnectViewModel() + viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") + advanceUntilIdle() + + assertEquals("192.168.1.5", viewModel.uiState.value.host) + assertEquals("9119", viewModel.uiState.value.port) + assertEquals("TEST_TOKEN", viewModel.uiState.value.token) + assertTrue(viewModel.uiState.value.connectionSuccess) + + unmockkStatic(android.net.Uri::class) + } + + @Test + fun testConnectViewModel_onPairingString_base64Format() = + runTest { + mockkStatic(android.util.Base64::class) + val base64Str = "eyJob3N0IjoiMTkyLjE2OC4xLjUiLCJwb3J0Ijo5MTE5LCJ0b2tlbiI6IlRFU1RfVE9LRU4ifQ==" + every { android.util.Base64.decode(base64Str, any()) } answers { + java.util.Base64 + .getDecoder() + .decode(base64Str) + } + + val statusResponse = mockk() + coEvery { mockApiService.getStatus() } returns Response.success(statusResponse) + + val viewModel = ConnectViewModel() + viewModel.onPairingString(base64Str) + advanceUntilIdle() + + assertEquals("192.168.1.5", viewModel.uiState.value.host) + assertEquals("9119", viewModel.uiState.value.port) + assertEquals("TEST_TOKEN", viewModel.uiState.value.token) + assertTrue(viewModel.uiState.value.connectionSuccess) + + unmockkStatic(android.util.Base64::class) + } }