* 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:
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
|
||||
}
|
||||
Reference in new issue
Block a user