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:okhttp:4.12.0")
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor: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
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("io.mockk:mockk:1.13.12")
|
testImplementation("io.mockk:mockk:1.13.12")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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
|
<application
|
||||||
android:name=".HermesApplication"
|
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) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
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(
|
AddHostDialog(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onDismiss = { showAddDialog = false },
|
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 testStatus: HermesServerStatus? = null,
|
||||||
val testError: String? = null,
|
val testError: String? = null,
|
||||||
val isAuthenticating: Boolean = false,
|
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(
|
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