feat(hosts): QR scanner onboarding, pairing payload parser, and preview verification
This commit is contained in:
parent
252008b389
commit
c27d902d3c
10 changed files with 806 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue