feat(hosts): QR scanner onboarding, pairing payload parser, and preview verification

This commit is contained in:
Ochenstarik 2026-08-24 10:48:30 +07:00
parent 252008b389
commit c27d902d3c
10 changed files with 806 additions and 2 deletions

View file

@ -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")

View file

@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".HermesApplication"

View file

@ -0,0 +1,69 @@
package app.hermes.mobile.core.pairing
import kotlinx.serialization.json.Json
import java.net.URI
import java.util.Base64
import java.util.UUID
object HermesPairingParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(rawUri: String): PairingValidationResult {
try {
val uri = URI(rawUri)
if (uri.scheme != "hermes" || uri.host != "pair") {
return PairingValidationResult.InvalidPayload("Invalid scheme or host")
}
val query = uri.query
val dataParams = query?.split("&")?.map { it.split("=") }?.firstOrNull { it[0] == "data" }
val data = if (dataParams != null && dataParams.size > 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<HermesPairingPayload>(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}")
}
}
}

View file

@ -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
}

View file

@ -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")
}
}
)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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")
}
}
)
}

View file

@ -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<PreviewView?>(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()
}
}
}

View file

@ -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)
}
}

View file

@ -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()) }
}
}