feat(#350): pairing login flow with QR scan + manual entry (#421)

* feat(#350): pairing login flow with QR scan + manual entry

Adds PairingCodeEntryScreen with CameraX + ML Kit QR scanning
and manual code entry. The screen parses hermes://connect URIs,
Base64-encoded JSON, and raw token formats, validates via
/api/status, and saves credentials on success.

- New PairingCodeEntryScreen + PairingCodeEntryViewModel
- CameraX 1.4.1 + ML Kit barcode-scanning 17.3.0
- CAMERA permission + proguard rules
- Landing screen Pairing Login button wired correctly

* fix(#350): resolve smart-cast on delegated property for errorMessage

Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>

* refactor(#421): remove QR scanner from pairing login

- Remove CameraX + ML Kit barcode dependencies
- Drop CAMERA permission and camera feature from manifest
- Strip QrScannerTab, QrCodeScannerView, processFrame composables
- Remove camera permission state/methods from ViewModel
- Purge all QR/camera strings from strings.xml
- Delete ML Kit proguard rules

The pairing login now shows only the manual code entry UI.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-30 10:08:01 +03:00
1 parent 58bd78bd79
commit 7811a35d29
6 files changed
+346 -3

No files matched your search

@@ -54,6 +54,7 @@ import com.m57.hermescontrol.theme.LocalHermesStatusColors
import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
import com.m57.hermescontrol.ui.pairing.PairingCodeEntryScreen as PairingCodeEntryScreenContent
private fun resolveBottomNavItems(names: List<String>): List<ScreenDefinition> =
names.mapNotNull { name -> ScreenRegistry.ALL_SCREENS.firstOrNull { it.key::class.simpleName == name } }
@@ -70,7 +71,7 @@ private fun appEntryProvider(
NavigationController.backStack?.add(AuthLoginScreen)
},
onPairingLogin = {
NavigationController.backStack?.add(PairingScreen)
NavigationController.backStack?.add(PairingCodeEntryScreen)
},
)
}
@@ -86,6 +87,17 @@ private fun appEntryProvider(
)
}
entry<PairingCodeEntryScreen> {
PairingCodeEntryScreenContent(
onConnected = {
NavigationController.resetTo(ChatScreen)
},
onBack = {
NavigationController.goBack()
},
)
}
ScreenRegistry.ALL_SCREENS.forEach { screen ->
addEntryProvider(clazz = screen.key::class) {
screen.content(sessionId, openDrawer)
@@ -123,7 +135,8 @@ fun MainNavigation(sessionId: String? = null) {
val showBottomBar =
currentScreen != LandingScreen &&
currentScreen != AuthLoginScreen
currentScreen != AuthLoginScreen &&
currentScreen != PairingCodeEntryScreen
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
@@ -27,6 +27,8 @@ import kotlinx.serialization.Serializable
@Serializable data object PairingScreen : NavKey
@Serializable data object PairingCodeEntryScreen : NavKey
@Serializable data object ConfigScreen : NavKey
@Serializable data object McpServersScreen : NavKey
@@ -0,0 +1,146 @@
package com.m57.hermescontrol.ui.pairing
import android.app.Application
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
/**
* Pairing code entry screen that accepts a manual pairing string
* (hermes://connect URI, Base64-encoded JSON, or raw token).
*
* On successful connection (stored credentials, rebuilt ApiClient) the
* [onConnected] callback fires to navigate to the main chat screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PairingCodeEntryScreen(
onConnected: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PairingCodeEntryViewModel =
viewModel(
factory =
run {
val app = LocalContext.current.applicationContext as Application
PairingCodeEntryViewModelFactory(app)
},
),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// ── Navigate to main screen on success ──────────────────────────────
LaunchedEffect(state.connectionSuccess) {
if (state.connectionSuccess) {
onConnected()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pairing_code_entry_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_back),
)
}
},
)
},
) { padding ->
Column(
modifier = modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.pairing_code_entry_manual_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 16.dp),
)
OutlinedTextField(
value = state.manualCode,
onValueChange = viewModel::onManualCodeChange,
label = { Text(stringResource(R.string.pairing_code_entry_code_label)) },
placeholder = {
Text(stringResource(R.string.connect_placeholder_pairing))
},
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
enabled = !state.isConnecting,
)
Spacer(Modifier.height(16.dp))
val errorMessage = state.errorMessage
if (errorMessage != null) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(12.dp))
}
Button(
onClick = { viewModel.onCodeDetected(state.manualCode) },
modifier =
Modifier
.fillMaxWidth()
.height(52.dp)
.padding(horizontal = 16.dp),
enabled = state.manualCode.isNotBlank() && !state.isConnecting,
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text(stringResource(R.string.action_connect))
}
}
}
}
}
@@ -0,0 +1,178 @@
package com.m57.hermescontrol.ui.pairing
import android.app.Application
import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonParser
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* UI state for the pairing code entry screen.
*/
data class PairingCodeEntryUiState(
val manualCode: String = "",
val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false,
val errorMessage: String? = null,
)
/**
* ViewModel for the manual code entry pairing flow.
*
* Accepts pairing strings in two formats handled by [onCodeDetected]:
* - `hermes://connect?host=...&port=...&token=...` (URI format)
* - Base64-encoded JSON with `host`, `port`, `token` fields
*/
class PairingCodeEntryViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(PairingCodeEntryUiState())
val uiState: StateFlow<PairingCodeEntryUiState> = _uiState.asStateFlow()
companion object {
private const val TAG = "PairingCodeEntryVM"
}
fun onManualCodeChange(value: String) {
_uiState.update { it.copy(manualCode = value.trim(), errorMessage = null) }
}
/**
* Process a pairing code (from manual entry).
*
* Supported formats:
* 1. `hermes://connect?host=<host>&port=<port>&token=<token>`
* 2. Base64-encoded JSON with `host`, `port`, `token` keys
* 3. Raw token string (>= 32 alphanumeric chars) — uses defaults
*/
fun onCodeDetected(code: String) {
if (code.isBlank()) return
val trimmed = code.trim()
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
Log.d(TAG, "Processing pairing code (${trimmed.take(64)}…)")
try {
// ── Format 1: hermes://connect URI ──────────────────────────
if (trimmed.startsWith("hermes://connect?", ignoreCase = true)) {
val uri = android.net.Uri.parse(trimmed)
val host = uri.getQueryParameter("host")
val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) {
connectToDashboard(host, port.toIntOrNull() ?: 9119, token)
return
}
// URI parsed but missing required params
setError(app.getString(R.string.connect_error_missing_fields))
return
}
// ── Format 2: Base64-encoded JSON ───────────────────────────
try {
val decodedBytes = Base64.decode(trimmed, Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = JsonParser.parseString(decodedString).asJsonObject
val host = json.get("host")?.asString
val port =
json.get("port")?.asInt?.toString()
?: json.get("port")?.asString
val token = json.get("token")?.asString
// Accept raw "host" + "port" + "token", or a composite "pairing" payload
if (host != null && port != null && token != null) {
connectToDashboard(host, port.toIntOrNull() ?: 9119, token)
return
}
// JSON parsed but missing required fields
setError(app.getString(R.string.connect_error_missing_fields))
return
}
// Decoded to string but not JSON
setError(app.getString(R.string.connect_error_malformed))
return
} catch (_: IllegalArgumentException) {
// Not valid Base64 — check if it looks like a raw token
if (trimmed.length >= 32 && trimmed.matches(Regex("[A-Za-z0-9_\\-]+"))) {
connectToDashboard("127.0.0.1", 9119, trimmed)
return
}
setError(app.getString(R.string.connect_error_malformed))
}
} catch (e: Exception) {
Log.e(TAG, "Unexpected parse error", e)
setError(e.message ?: app.getString(R.string.connect_error_malformed))
}
}
private fun connectToDashboard(
host: String,
port: Int,
token: String,
) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(host, port, token)
safeApiCall { tempApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
AuthManager.setToken(token)
AuthManager.setHost(host)
AuthManager.setPort(port)
ApiClient.rebuild()
_uiState.update { it.copy(isConnecting = false, connectionSuccess = true) }
}
is NetworkResult.Failure -> {
val msg = buildErrorMessage(result)
setError(msg)
}
}
}
}
private fun buildErrorMessage(result: NetworkResult.Failure): String {
return when (val err = result.error) {
is NetworkError.Http -> {
when (err.code) {
401 -> app.getString(R.string.connect_error_401)
403 -> app.getString(R.string.connect_error_403)
else -> app.getString(R.string.connect_error_http_code, err.code)
}
}
is NetworkError.AuthExpired -> app.getString(R.string.connect_error_401)
is NetworkError.Connection ->
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
is NetworkError.Unknown ->
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
}
}
private fun setError(msg: String) {
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
}
}
class PairingCodeEntryViewModelFactory(
private val app: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = PairingCodeEntryViewModel(app) as T
}