diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index bb5e0cb6..0557a31d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -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): List = 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 { + 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() } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index bacaf794..a9ec0109 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryScreen.kt new file mode 100644 index 00000000..91d79edf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryScreen.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt new file mode 100644 index 00000000..74add1d9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt @@ -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 = _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=&port=&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 create(modelClass: Class): T = PairingCodeEntryViewModel(app) as T +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad95238e..90590340 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,6 +445,11 @@ Auth Login Pairing Login + + Pairing Login + Paste the pairing string from your Hermes dashboard below. + Pairing Code + Loading… Fetching model providers… diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7214da6d..c9c62e09 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,7 +79,6 @@ sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher # Image loading coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } - [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }