From c27d902d3c2da72ededc9c15c96a1d5ee683602e Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 24 Aug 2026 10:48:30 +0700 Subject: [PATCH] feat(hosts): QR scanner onboarding, pairing payload parser, and preview verification --- app/build.gradle.kts | 9 + app/src/main/AndroidManifest.xml | 2 + .../core/pairing/HermesPairingParser.kt | 69 +++++++ .../mobile/core/pairing/PairingModels.kt | 25 +++ .../mobile/feature/hosts/HostsScreen.kt | 40 +++- .../mobile/feature/hosts/HostsViewModel.kt | 66 ++++++- .../feature/hosts/PairingPreviewDialog.kt | 101 +++++++++++ .../mobile/feature/hosts/QrScannerSheet.kt | 167 +++++++++++++++++ .../core/pairing/HermesPairingParserTest.kt | 171 ++++++++++++++++++ .../mobile/feature/hosts/HostsPairingTest.kt | 158 ++++++++++++++++ 10 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/hermes/mobile/core/pairing/HermesPairingParser.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/hosts/QrScannerSheet.kt create mode 100644 app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt create mode 100644 app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt 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()) } + } +}