diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dfb1006..ba6b5a8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -104,6 +104,15 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ // CameraX & Barcode Scanning for QR Onboarding
+ val cameraxVersion = "1.4.1"
+ implementation("androidx.camera:camera-core:$cameraxVersion")
+ implementation("androidx.camera:camera-camera2:$cameraxVersion")
+ implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
+ implementation("androidx.camera:camera-view:$cameraxVersion")
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
+ implementation("com.google.guava:guava:33.4.0-android")
+
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.12")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4af185e..2760767 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
1) dataParams[1] else null
+ if (data == null) {
+ return PairingValidationResult.InvalidPayload("Missing data parameter")
+ }
+
+ val decodedBytes = try {
+ Base64.getUrlDecoder().decode(data)
+ } catch (e: IllegalArgumentException) {
+ return PairingValidationResult.InvalidPayload("Malformed Base64")
+ }
+
+ val jsonString = String(decodedBytes, Charsets.UTF_8)
+ val payload = try {
+ json.decodeFromString(jsonString)
+ } catch (e: Exception) {
+ return PairingValidationResult.InvalidPayload("Invalid JSON payload")
+ }
+
+ if (payload.v != 1) {
+ return PairingValidationResult.InvalidVersion(payload.v)
+ }
+ if (payload.type != "hermes-pair") {
+ return PairingValidationResult.InvalidPayload("Invalid type")
+ }
+ try {
+ UUID.fromString(payload.hostId)
+ } catch (e: IllegalArgumentException) {
+ return PairingValidationResult.InvalidPayload("Invalid host_id")
+ }
+ if (payload.host.isBlank()) {
+ return PairingValidationResult.InvalidPayload("Host is empty")
+ }
+ if (payload.port !in 1..65535) {
+ return PairingValidationResult.InvalidPayload("Invalid port")
+ }
+ if (payload.scheme != "http" && payload.scheme != "https") {
+ return PairingValidationResult.InvalidScheme("Scheme must be http or https")
+ }
+
+ if (payload.expiresAt <= System.currentTimeMillis() / 1000) {
+ return PairingValidationResult.Expired(payload.expiresAt)
+ }
+
+ return PairingValidationResult.Success(payload)
+ } catch (e: Exception) {
+ return PairingValidationResult.InvalidPayload("Unknown error: ${e.message}")
+ }
+ }
+}
diff --git a/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt b/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt
new file mode 100644
index 0000000..3225e3c
--- /dev/null
+++ b/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt
@@ -0,0 +1,25 @@
+package app.hermes.mobile.core.pairing
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class HermesPairingPayload(
+ val v: Int,
+ val type: String,
+ @SerialName("host_id") val hostId: String,
+ val name: String,
+ val host: String,
+ val port: Int,
+ val scheme: String = "http",
+ @SerialName("expires_at") val expiresAt: Long,
+ val nonce: String
+)
+
+sealed interface PairingValidationResult {
+ data class Success(val payload: HermesPairingPayload) : PairingValidationResult
+ data class Expired(val expiresAt: Long) : PairingValidationResult
+ data class InvalidScheme(val reason: String) : PairingValidationResult
+ data class InvalidVersion(val version: Int) : PairingValidationResult
+ data class InvalidPayload(val reason: String) : PairingValidationResult
+}
diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt
index 255f959..8e90e10 100644
--- a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt
+++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt
@@ -82,6 +82,11 @@ fun HostsScreen(
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
+ },
+ actions = {
+ IconButton(onClick = { viewModel.startQrScan() }) {
+ Icon(Icons.Default.Add, contentDescription = "Scan QR")
+ }
}
)
},
@@ -174,7 +179,7 @@ fun HostsScreen(
}
}
- if (showAddDialog) {
+ if (showAddDialog) {
AddHostDialog(
uiState = uiState,
onDismiss = { showAddDialog = false },
@@ -185,6 +190,39 @@ fun HostsScreen(
}
)
}
+
+ if (uiState.qrScanActive) {
+ QrScannerSheet(
+ onQrScanned = { viewModel.onQrScanned(it) },
+ onDismiss = { viewModel.dismissQrScan() }
+ )
+ }
+
+ uiState.scannedPayload?.let { payload ->
+ val existingHost = hosts.find { it.id.value == payload.hostId }
+ PairingPreviewDialog(
+ payload = payload,
+ isExistingHost = existingHost != null,
+ existingHostName = existingHost?.displayName,
+ onConfirm = { allowCleartext ->
+ viewModel.confirmPairing(payload, allowCleartext)
+ },
+ onCancel = { viewModel.dismissQrScan() }
+ )
+ }
+
+ if (uiState.qrScanError != null) {
+ AlertDialog(
+ onDismissRequest = { viewModel.dismissQrScan() },
+ title = { Text("QR Scan Error") },
+ text = { Text(uiState.qrScanError ?: "") },
+ confirmButton = {
+ TextButton(onClick = { viewModel.dismissQrScan() }) {
+ Text("OK")
+ }
+ }
+ )
+ }
}
}
diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt
index 6e9f0ee..57d63b5 100644
--- a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt
+++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt
@@ -22,7 +22,10 @@ data class HostsUiState(
val testStatus: HermesServerStatus? = null,
val testError: String? = null,
val isAuthenticating: Boolean = false,
- val authError: String? = null
+ val authError: String? = null,
+ val qrScanActive: Boolean = false,
+ val scannedPayload: app.hermes.mobile.core.pairing.HermesPairingPayload? = null,
+ val qrScanError: String? = null
)
class HostsViewModel(
@@ -115,4 +118,65 @@ class HostsViewModel(
}
}
}
+
+ fun startQrScan() {
+ _uiState.value = _uiState.value.copy(qrScanActive = true, scannedPayload = null, qrScanError = null)
+ }
+
+ fun dismissQrScan() {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, scannedPayload = null, qrScanError = null)
+ }
+
+ fun onQrScanned(rawUri: String) {
+ when (val result = app.hermes.mobile.core.pairing.HermesPairingParser.parse(rawUri)) {
+ is app.hermes.mobile.core.pairing.PairingValidationResult.Success -> {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, scannedPayload = result.payload, qrScanError = null)
+ }
+ is app.hermes.mobile.core.pairing.PairingValidationResult.Expired -> {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, qrScanError = "QR code has expired")
+ }
+ is app.hermes.mobile.core.pairing.PairingValidationResult.InvalidPayload -> {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, qrScanError = "Invalid QR code: ${result.reason}")
+ }
+ is app.hermes.mobile.core.pairing.PairingValidationResult.InvalidScheme -> {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, qrScanError = "Invalid scheme: ${result.reason}")
+ }
+ is app.hermes.mobile.core.pairing.PairingValidationResult.InvalidVersion -> {
+ _uiState.value = _uiState.value.copy(qrScanActive = false, qrScanError = "Unsupported QR version: ${result.version}")
+ }
+ }
+ }
+
+ fun confirmPairing(payload: app.hermes.mobile.core.pairing.HermesPairingPayload, allowCleartext: Boolean) {
+ viewModelScope.launch {
+ val existingHost = connectionManager.hostDao.getHost(payload.hostId)
+ val hostToConnect = if (existingHost != null) {
+ val updatedHost = HermesHost(
+ id = HermesHostId(payload.hostId),
+ displayName = payload.name,
+ baseUrl = "${payload.scheme}://${payload.host}:${payload.port}",
+ allowCleartext = allowCleartext,
+ enabled = existingHost.enabled,
+ lastSeenAt = existingHost.lastSeenAt,
+ lastKnownStatus = HostStatus.valueOf(existingHost.lastKnownStatus)
+ )
+ connectionManager.updateHost(updatedHost)
+ updatedHost
+ } else {
+ val newHost = HermesHost(
+ id = HermesHostId(payload.hostId),
+ displayName = payload.name,
+ baseUrl = "${payload.scheme}://${payload.host}:${payload.port}",
+ allowCleartext = allowCleartext,
+ enabled = true,
+ lastSeenAt = System.currentTimeMillis(),
+ lastKnownStatus = HostStatus.OFFLINE
+ )
+ connectionManager.addHost(newHost)
+ newHost
+ }
+ _uiState.value = _uiState.value.copy(scannedPayload = null)
+ connectHost(hostToConnect.id)
+ }
+ }
}
diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt
new file mode 100644
index 0000000..73f9f26
--- /dev/null
+++ b/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt
@@ -0,0 +1,101 @@
+package app.hermes.mobile.feature.hosts
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.hermes.mobile.core.pairing.HermesPairingPayload
+import app.hermes.mobile.core.network.HermesRestClient
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PairingPreviewDialog(
+ payload: HermesPairingPayload,
+ isExistingHost: Boolean,
+ existingHostName: String?,
+ onConfirm: (Boolean) -> Unit,
+ onCancel: () -> Unit
+) {
+ var probeStatus by remember { mutableStateOf("Probing...") }
+ var allowCleartext by remember { mutableStateOf(payload.scheme == "http") }
+ val coroutineScope = rememberCoroutineScope()
+ val restClient = remember { HermesRestClient() }
+
+ LaunchedEffect(payload) {
+ coroutineScope.launch {
+ try {
+ val url = "${payload.scheme}://${payload.host}:${payload.port}"
+ val result = withContext(Dispatchers.IO) {
+ restClient.getStatus(url, allowCleartext)
+ }
+ if (result.isSuccess) {
+ val status = result.getOrNull()
+ if (status != null) {
+ probeStatus = "Hermes v${status.version} | Auth: ${if (status.authRequired) "Required" else "None"}"
+ }
+ } else {
+ probeStatus = "Probe failed: ${result.exceptionOrNull()?.message}"
+ }
+ } catch (e: Exception) {
+ probeStatus = "Probe failed: ${e.message}"
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onCancel,
+ title = { Text("Pairing Preview") },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Host: ${payload.name}", style = MaterialTheme.typography.bodyLarge)
+ Text("Address: ${payload.scheme}://${payload.host}:${payload.port}", style = MaterialTheme.typography.bodyMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text("Status: $probeStatus", style = MaterialTheme.typography.bodySmall)
+
+ if (isExistingHost) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) {
+ Text(
+ "Will update endpoint for existing host '${existingHostName ?: payload.name}'",
+ modifier = Modifier.padding(8.dp),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+
+ if (payload.scheme == "http") {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Allow Cleartext", style = MaterialTheme.typography.bodyMedium)
+ Text("Security warning: HTTP is insecure", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
+ }
+ Switch(
+ checked = allowCleartext,
+ onCheckedChange = { allowCleartext = it }
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm(allowCleartext) }) {
+ Text(if (isExistingHost) "Update Host" else "Add Host")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onCancel) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/QrScannerSheet.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/QrScannerSheet.kt
new file mode 100644
index 0000000..373c314
--- /dev/null
+++ b/app/src/main/java/app/hermes/mobile/feature/hosts/QrScannerSheet.kt
@@ -0,0 +1,167 @@
+package app.hermes.mobile.feature.hosts
+
+import android.Manifest
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import java.util.concurrent.Executors
+import android.content.pm.PackageManager
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun QrScannerSheet(
+ onQrScanned: (String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ var hasCameraPermission by remember { mutableStateOf(false) }
+
+ val context = LocalContext.current
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ hasCameraPermission = isGranted
+ }
+
+ LaunchedEffect(Unit) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ hasCameraPermission = true
+ } else {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxHeight(0.9f)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Scan QR Code", style = MaterialTheme.typography.titleLarge)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (hasCameraPermission) {
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ CameraPreview(onQrScanned = onQrScanned)
+ }
+ } else {
+ Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Text("Camera permission denied.")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ }
+}
+
+@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
+@Composable
+fun CameraPreview(onQrScanned: (String) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
+ var previewView by remember { mutableStateOf(null) }
+
+ val executor = remember { Executors.newSingleThreadExecutor() }
+ var isScanning by remember { mutableStateOf(true) }
+
+ AndroidView(
+ factory = { ctx ->
+ PreviewView(ctx).also {
+ previewView = it
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ LaunchedEffect(cameraProviderFuture, previewView, isScanning) {
+ if (previewView == null || !isScanning) return@LaunchedEffect
+
+ cameraProviderFuture.addListener({
+ val cameraProvider = cameraProviderFuture.get()
+ val preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView!!.surfaceProvider)
+ }
+
+ val options = BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ .build()
+ val scanner = BarcodeScanning.getClient(options)
+
+ val imageAnalysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+
+ imageAnalysis.setAnalyzer(executor) { imageProxy ->
+ val mediaImage = imageProxy.image
+ if (mediaImage != null && isScanning) {
+ val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+ scanner.process(image)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ val rawValue = barcode.rawValue
+ if (rawValue != null && rawValue.startsWith("hermes://pair")) {
+ isScanning = false
+ onQrScanned(rawValue)
+ break
+ }
+ }
+ }
+ .addOnCompleteListener {
+ imageProxy.close()
+ }
+ } else {
+ imageProxy.close()
+ }
+ }
+
+ val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ preview,
+ imageAnalysis
+ )
+ } catch (e: Exception) {
+ Log.e("QrScannerSheet", "Use case binding failed", e)
+ }
+ }, ContextCompat.getMainExecutor(context))
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ executor.shutdown()
+ }
+ }
+}
diff --git a/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt b/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt
new file mode 100644
index 0000000..858aee2
--- /dev/null
+++ b/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt
@@ -0,0 +1,171 @@
+package app.hermes.mobile.core.pairing
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Base64
+import java.util.UUID
+
+class HermesPairingParserTest {
+
+ private fun encodePayload(json: String): String {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(json.toByteArray())
+ }
+
+ @Test
+ fun testValidPairingPayloadParsing() {
+ val futureTime = (System.currentTimeMillis() / 1000) + 3600
+ val hostId = UUID.randomUUID().toString()
+ val json = """
+ {
+ "v": 1,
+ "type": "hermes-pair",
+ "host_id": "$hostId",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 9119,
+ "scheme": "http",
+ "expires_at": $futureTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.Success)
+ val payload = (result as PairingValidationResult.Success).payload
+ assertEquals(1, payload.v)
+ assertEquals(hostId, payload.hostId)
+ assertEquals("My Server", payload.name)
+ assertEquals("192.168.1.5", payload.host)
+ assertEquals(9119, payload.port)
+ assertEquals("http", payload.scheme)
+ }
+
+ @Test
+ fun testExpiredPayloadRejection() {
+ val pastTime = (System.currentTimeMillis() / 1000) - 3600
+ val hostId = UUID.randomUUID().toString()
+ val json = """
+ {
+ "v": 1,
+ "type": "hermes-pair",
+ "host_id": "$hostId",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 9119,
+ "scheme": "http",
+ "expires_at": $pastTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.Expired)
+ }
+
+ @Test
+ fun testInvalidVersionRejection() {
+ val futureTime = (System.currentTimeMillis() / 1000) + 3600
+ val hostId = UUID.randomUUID().toString()
+ val json = """
+ {
+ "v": 2,
+ "type": "hermes-pair",
+ "host_id": "$hostId",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 9119,
+ "scheme": "http",
+ "expires_at": $futureTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.InvalidVersion)
+ }
+
+ @Test
+ fun testMalformedBase64Rejection() {
+ val uri = "hermes://pair?data=ThisIs!Not!Valid!Base64"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.InvalidPayload)
+ }
+
+ @Test
+ fun testInvalidPortRejection() {
+ val futureTime = (System.currentTimeMillis() / 1000) + 3600
+ val hostId = UUID.randomUUID().toString()
+ val json = """
+ {
+ "v": 1,
+ "type": "hermes-pair",
+ "host_id": "$hostId",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 70000,
+ "scheme": "http",
+ "expires_at": $futureTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.InvalidPayload)
+ }
+
+ @Test
+ fun testInvalidSchemeRejection() {
+ val futureTime = (System.currentTimeMillis() / 1000) + 3600
+ val hostId = UUID.randomUUID().toString()
+ val json = """
+ {
+ "v": 1,
+ "type": "hermes-pair",
+ "host_id": "$hostId",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 9119,
+ "scheme": "ftp",
+ "expires_at": $futureTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.InvalidScheme)
+ }
+
+ @Test
+ fun testMissingHostIdRejection() {
+ val futureTime = (System.currentTimeMillis() / 1000) + 3600
+ val json = """
+ {
+ "v": 1,
+ "type": "hermes-pair",
+ "name": "My Server",
+ "host": "192.168.1.5",
+ "port": 9119,
+ "scheme": "http",
+ "expires_at": $futureTime,
+ "nonce": "random-nonce"
+ }
+ """.trimIndent()
+
+ val uri = "hermes://pair?data=${encodePayload(json)}"
+ val result = HermesPairingParser.parse(uri)
+
+ assertTrue(result is PairingValidationResult.InvalidPayload)
+ }
+}
diff --git a/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt b/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt
new file mode 100644
index 0000000..0622346
--- /dev/null
+++ b/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt
@@ -0,0 +1,158 @@
+package app.hermes.mobile.feature.hosts
+
+import app.hermes.mobile.core.model.HermesHost
+import app.hermes.mobile.core.model.HermesHostId
+import app.hermes.mobile.core.pairing.HermesPairingPayload
+import app.hermes.mobile.core.runtime.HermesConnectionManager
+import app.hermes.mobile.core.security.TokenVault
+import app.hermes.mobile.core.storage.HostDao
+import app.hermes.mobile.core.storage.HostEntity
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import java.util.UUID
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class HostsPairingTest {
+
+ private lateinit var viewModel: HostsViewModel
+ private lateinit var connectionManager: HermesConnectionManager
+ private lateinit var tokenVault: TokenVault
+ private lateinit var hostDao: HostDao
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ hostDao = mockk(relaxed = true)
+ tokenVault = mockk(relaxed = true)
+ connectionManager = mockk(relaxed = true)
+ coEvery { connectionManager.hostDao } returns hostDao
+
+ viewModel = HostsViewModel(connectionManager, tokenVault, mockk(relaxed = true), mockk(relaxed = true))
+ }
+
+ @After
+ fun teardown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testPairingNewHostInsertsHost() = runTest {
+ val hostId = UUID.randomUUID().toString()
+ val payload = HermesPairingPayload(
+ v = 1,
+ type = "hermes-pair",
+ hostId = hostId,
+ name = "New Test Server",
+ host = "192.168.1.10",
+ port = 9119,
+ scheme = "https",
+ expiresAt = (System.currentTimeMillis() / 1000) + 3600,
+ nonce = "nonce"
+ )
+
+ coEvery { hostDao.getHost(hostId) } returns null
+ coEvery { connectionManager.addHost(any()) } returns Unit
+ coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit)
+
+ viewModel.confirmPairing(payload, allowCleartext = false)
+
+ coVerify {
+ connectionManager.addHost(match {
+ it.id.value == hostId &&
+ it.displayName == "New Test Server" &&
+ it.baseUrl == "https://192.168.1.10:9119" &&
+ !it.allowCleartext
+ })
+ }
+ coVerify { connectionManager.connectHost(HermesHostId(hostId)) }
+ }
+
+ @Test
+ fun testPairingExistingHostUpdatesEndpointWithoutDuplicating() = runTest {
+ val hostId = UUID.randomUUID().toString()
+ val existingEntity = HostEntity(
+ id = hostId,
+ displayName = "Old Name",
+ baseUrl = "http://192.168.1.5:9119",
+ allowCleartext = true,
+ enabled = true,
+ lastSeenAt = 1000L,
+ lastKnownStatus = "OFFLINE"
+ )
+
+ val payload = HermesPairingPayload(
+ v = 1,
+ type = "hermes-pair",
+ hostId = hostId,
+ name = "Updated Server Name",
+ host = "192.168.1.20",
+ port = 9119,
+ scheme = "http",
+ expiresAt = (System.currentTimeMillis() / 1000) + 3600,
+ nonce = "nonce"
+ )
+
+ coEvery { hostDao.getHost(hostId) } returns existingEntity
+ coEvery { connectionManager.updateHost(any()) } returns Unit
+ coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit)
+
+ viewModel.confirmPairing(payload, allowCleartext = true)
+
+ coVerify(exactly = 0) { connectionManager.addHost(any()) }
+ coVerify {
+ connectionManager.updateHost(match {
+ it.id.value == hostId &&
+ it.displayName == "Updated Server Name" &&
+ it.baseUrl == "http://192.168.1.20:9119" &&
+ it.allowCleartext
+ })
+ }
+ coVerify { connectionManager.connectHost(HermesHostId(hostId)) }
+ }
+
+ @Test
+ fun testExistingTokensPreservedOnHostUpdate() = runTest {
+ // Just verify updateHost is called and tokenVault clear isn't.
+ val hostId = UUID.randomUUID().toString()
+ val existingEntity = HostEntity(
+ id = hostId,
+ displayName = "Old Name",
+ baseUrl = "http://192.168.1.5:9119",
+ allowCleartext = true,
+ enabled = true,
+ lastSeenAt = 1000L,
+ lastKnownStatus = "OFFLINE"
+ )
+
+ val payload = HermesPairingPayload(
+ v = 1,
+ type = "hermes-pair",
+ hostId = hostId,
+ name = "Updated Server Name",
+ host = "192.168.1.20",
+ port = 9119,
+ scheme = "http",
+ expiresAt = (System.currentTimeMillis() / 1000) + 3600,
+ nonce = "nonce"
+ )
+
+ coEvery { hostDao.getHost(hostId) } returns existingEntity
+
+ viewModel.confirmPairing(payload, allowCleartext = true)
+
+ coVerify(exactly = 0) { tokenVault.clearTokens(any()) }
+ }
+}