* 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:
@@ -54,6 +54,7 @@ import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
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> =
|
private fun resolveBottomNavItems(names: List<String>): List<ScreenDefinition> =
|
||||||
names.mapNotNull { name -> ScreenRegistry.ALL_SCREENS.firstOrNull { it.key::class.simpleName == name } }
|
names.mapNotNull { name -> ScreenRegistry.ALL_SCREENS.firstOrNull { it.key::class.simpleName == name } }
|
||||||
@@ -70,7 +71,7 @@ private fun appEntryProvider(
|
|||||||
NavigationController.backStack?.add(AuthLoginScreen)
|
NavigationController.backStack?.add(AuthLoginScreen)
|
||||||
},
|
},
|
||||||
onPairingLogin = {
|
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 ->
|
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||||
addEntryProvider(clazz = screen.key::class) {
|
addEntryProvider(clazz = screen.key::class) {
|
||||||
screen.content(sessionId, openDrawer)
|
screen.content(sessionId, openDrawer)
|
||||||
@@ -123,7 +135,8 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
|
|
||||||
val showBottomBar =
|
val showBottomBar =
|
||||||
currentScreen != LandingScreen &&
|
currentScreen != LandingScreen &&
|
||||||
currentScreen != AuthLoginScreen
|
currentScreen != AuthLoginScreen &&
|
||||||
|
currentScreen != PairingCodeEntryScreen
|
||||||
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
|
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
|
||||||
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
|
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object PairingScreen : NavKey
|
@Serializable data object PairingScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object PairingCodeEntryScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ConfigScreen : NavKey
|
@Serializable data object ConfigScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object McpServersScreen : 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
|
||||||
|
}
|
||||||
@@ -445,6 +445,11 @@
|
|||||||
<string name="landing_action_auth_login">Auth Login</string>
|
<string name="landing_action_auth_login">Auth Login</string>
|
||||||
<string name="landing_action_pairing_login">Pairing Login</string>
|
<string name="landing_action_pairing_login">Pairing Login</string>
|
||||||
|
|
||||||
|
<!-- Pairing Code Entry Screen -->
|
||||||
|
<string name="pairing_code_entry_title">Pairing Login</string>
|
||||||
|
<string name="pairing_code_entry_manual_desc">Paste the pairing string from your Hermes dashboard below.</string>
|
||||||
|
<string name="pairing_code_entry_code_label">Pairing Code</string>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<string name="loading_state_subtitle_default">Loading…</string>
|
<string name="loading_state_subtitle_default">Loading…</string>
|
||||||
<string name="loading_state_subtitle_models">Fetching model providers…</string>
|
<string name="loading_state_subtitle_models">Fetching model providers…</string>
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher
|
|||||||
# Image loading
|
# Image loading
|
||||||
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user