diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 9de4f78a..1ff096a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -67,7 +67,6 @@ import com.m57.hermescontrol.ui.settings.SettingsViewModel 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 // NOTE: if a new Settings drill-down screen is added, its NavKey MUST be added // here too, or the bottom-nav Settings highlight will drop on that sub-page. @@ -113,9 +112,6 @@ private fun appEntryProvider( onAuthLogin = { NavigationController.navigateTo(AuthLoginScreen) }, - onPairingLogin = { - NavigationController.navigateTo(PairingCodeEntryScreen) - }, ) // Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619). DisableDrawerGestures() @@ -133,18 +129,6 @@ private fun appEntryProvider( DisableDrawerGestures() } - entry { - PairingCodeEntryScreenContent( - onConnected = { - NavigationController.resetTo(ChatScreen) - }, - onBack = { - NavigationController.goBack() - }, - ) - DisableDrawerGestures() - } - ScreenRegistry.ALL_SCREENS.forEach { screen -> addEntryProvider(clazz = screen.key::class) { screen.content(sessionId, openDrawer) @@ -231,8 +215,7 @@ fun MainNavigation(sessionId: String? = null) { val showBottomBar = currentScreen != LandingScreen && - currentScreen != AuthLoginScreen && - currentScreen != PairingCodeEntryScreen + currentScreen != AuthLoginScreen 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 9909c802..624951bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -25,10 +25,6 @@ import kotlinx.serialization.Serializable @Serializable data object AchievementsScreen : NavKey -@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/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index ebe687ba..1dc7d331 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.HistoryEdu @@ -39,7 +38,6 @@ import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent -import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent @@ -143,12 +141,6 @@ object ScreenRegistry { Icons.Filled.Psychology, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> ModelScreenContent(onOpenDrawer = openDrawer) }, - ScreenDefinition( - PairingScreen, - R.string.screen_pairing, - Icons.Filled.Devices, - DrawerSection.CONFIGURE, - ) { sessionId, openDrawer -> PairingScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( KeysScreen, R.string.screen_keys, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 3b2dbd1f..19b9c801 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -454,7 +454,7 @@ object AuthManager { } } - /** Compatibility accessors for legacy pairing payloads. */ + /** Convenience accessors for the resolved host/port (WebSocket + chat). */ fun getHost(): String = serverStore.getLatestState().resolvedHost fun getPort(): Int = serverStore.getLatestState().resolvedPort diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt deleted file mode 100644 index ca45442c..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.m57.hermescontrol.data.model -import kotlinx.serialization.Serializable - -@Serializable -data class PairingResponse( - val pending: List, - val approved: List, -) - -@Serializable -data class PairingItem( - val platform: String, - val user_id: String? = null, - val username: String? = null, - val display_name: String? = null, - val code: String? = null, - val created_at: String? = null, -) - -@Serializable -data class PairingApproveRequest( - val platform: String, - val code: String, -) - -@Serializable -data class PairingRevokeRequest( - val platform: String, - val user_id: String, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 7ce957b0..2d2a0d29 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -53,9 +53,6 @@ import com.m57.hermescontrol.data.model.OAuthProvidersResponse import com.m57.hermescontrol.data.model.OAuthStartResponse import com.m57.hermescontrol.data.model.OAuthSubmitRequest import com.m57.hermescontrol.data.model.OAuthSubmitResponse -import com.m57.hermescontrol.data.model.PairingApproveRequest -import com.m57.hermescontrol.data.model.PairingResponse -import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PortalResponse @@ -353,22 +350,6 @@ interface HermesApiService { @GET("api/plugins/hermes-achievements/recent-unlocks") suspend fun getRecentUnlocks(): Response> - @GET("api/pairing") - suspend fun getPairing(): Response - - @POST("api/pairing/approve") - suspend fun approvePairing( - @Body body: PairingApproveRequest, - ): Response - - @POST("api/pairing/revoke") - suspend fun revokePairing( - @Body body: PairingRevokeRequest, - ): Response - - @POST("api/pairing/clear-pending") - suspend fun clearPendingPairing(): Response - @GET("api/config/raw") suspend fun getRawConfig(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index 43a00c09..32f285e4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -102,7 +102,7 @@ val LocalDrawerGestureController = /** * Opt out of drawer gestures for a screen that does NOT use [HermesScaffold]. * - * Used by full-bleed entry screens (Landing, AuthLogin, PairingCodeEntry) that + * Used by full-bleed entry screens (Landing, AuthLogin) that * render their own layout. Scaffold-based screens should instead pass * `drawerGesturesEnabled = false` to [HermesScaffold]. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index d61a4808..815b8100 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -193,7 +193,7 @@ fun EmptyState( // Skeleton loaders for list screens. Replaces bare `CircularProgressIndicator` // in list screens to (1) reduce perceived latency (~2× per NN/g research) // and (2) eliminate layout shift when real content lands. Pair with -// `LoadingState()` for centered/one-shot contexts (Pairing flow, dialog +// `LoadingState()` for centered/one-shot contexts (one-shot flows, dialog // interiors) where a spinner is still appropriate. /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index de76f801..2db554ac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -167,7 +167,6 @@ fun ConnectScreen( } } - // Pairing (primary path) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt index 7e154417..4e54ac05 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/landing/LandingScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material.icons.filled.Bolt import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -31,7 +30,6 @@ import com.m57.hermescontrol.R @Composable fun LandingScreen( onAuthLogin: () -> Unit, - onPairingLogin: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -98,16 +96,6 @@ fun LandingScreen( style = MaterialTheme.typography.titleMedium, ) } - - OutlinedButton( - onClick = onPairingLogin, - modifier = Modifier.fillMaxWidth().height(56.dp), - ) { - Text( - text = stringResource(R.string.landing_action_pairing_login), - style = MaterialTheme.typography.titleMedium, - ) - } } } } 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 deleted file mode 100644 index 91d79edf..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryScreen.kt +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 146a6a34..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -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.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.OkHttpProvider -import com.m57.hermescontrol.data.remote.ServerEndpoint -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 -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -/** - * 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 = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject - val host = json["host"]?.jsonPrimitive?.content - val port = json["port"]?.jsonPrimitive?.content - val token = json["token"]?.jsonPrimitive?.content - // 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( - ServerEndpoint.fromLegacy(host, port).baseUrl.toString(), - 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 = - 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/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt deleted file mode 100644 index 64c21e97..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt +++ /dev/null @@ -1,261 +0,0 @@ -package com.m57.hermescontrol.ui.pairing - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.m57.hermescontrol.R -import com.m57.hermescontrol.ui.common.ErrorState -import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.LoadingState -import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.ToastEffect - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PairingScreen( - modifier: Modifier = Modifier, - onOpenDrawer: (() -> Unit)? = null, - viewModel: PairingViewModel = viewModel { PairingViewModel() }, -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.loadPairing() - } - - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) - - HermesScaffold( - title = { Text(stringResource(R.string.pairing_screen_title)) }, - navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadPairing() }, - ) { paddingValues -> - when { - state.isLoading && state.pairing == null -> { - LoadingState(modifier = Modifier.padding(paddingValues)) - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadPairing() }, - modifier = Modifier.padding(paddingValues), - ) - } - - else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading && state.pairing == null) { - CircularProgressIndicator() - } else if (state.errorMessage != null && state.pairing == null) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadPairing() }) { - Text(stringResource(R.string.action_retry)) - } - } - } else { - val pairing = state.pairing - val pending = pairing?.pending.orEmpty() - val approved = pairing?.approved.orEmpty() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (pending.isNotEmpty()) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.pairing_sec_pending), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - OutlinedButton( - onClick = { viewModel.clearPending() }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.pairing_action_clear_all)) - } - } - } - - items(pending, key = { "${it.platform}_${it.code ?: it.user_id}" }) { item -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer.copy( - alpha = 0.2f, - ), - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Platform: ${item.platform.uppercase()}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - item.code?.let { - Text( - text = "Pairing Code: $it", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - } - - Button( - onClick = { - viewModel.approvePairing(item.platform, item.code ?: "") - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.pairing_action_approve)) - } - } - } - } - } - - item { - Text( - text = stringResource(R.string.pairing_sec_approved), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - if (approved.isEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.pairing_empty_approved), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } else { - items(approved, key = { "${it.platform}_${it.user_id}" }) { item -> - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Platform: ${item.platform.uppercase()}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - stringResource( - R.string.pairing_label_user_id, - item.user_id ?: "Unknown", - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - item.username?.let { - Text( - text = "Username: @$it", - style = MaterialTheme.typography.bodySmall, - ) - } - } - - OutlinedButton( - onClick = { - viewModel.revokePairing(item.platform, item.user_id ?: "") - }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.pairing_action_revoke)) - } - } - } - } - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt deleted file mode 100644 index a77d2c28..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.m57.hermescontrol.ui.pairing - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.m57.hermescontrol.data.model.PairingApproveRequest -import com.m57.hermescontrol.data.model.PairingResponse -import com.m57.hermescontrol.data.model.PairingRevokeRequest -import com.m57.hermescontrol.data.remote.ApiClient -import com.m57.hermescontrol.data.remote.NetworkResult -import com.m57.hermescontrol.data.remote.safeApiCall -import com.m57.hermescontrol.ui.common.ToastHost -import com.m57.hermescontrol.ui.common.safeLaunchLoad -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 - -data class PairingUiState( - val isLoading: Boolean = false, - val isActionRunning: Boolean = false, - val pairing: PairingResponse? = null, - val errorMessage: String? = null, - val toastMessage: String? = null, -) - -class PairingViewModel : - ViewModel(), - ToastHost { - private val _uiState = MutableStateFlow(PairingUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun loadPairing() { - safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { data -> - _uiState.update { it.copy(isLoading = false, pairing = data) } - }, - onError = { errorMsg -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Failed to load pairing info: $errorMsg", - ) - } - }, - ) - } - - fun approvePairing( - platform: String, - code: String, - ) { - runAction("Approve") { - safeApiCall { ApiClient.hermesApi.approvePairing(PairingApproveRequest(platform, code)) } - } - } - - fun revokePairing( - platform: String, - userId: String, - ) { - runAction("Revoke") { - safeApiCall { ApiClient.hermesApi.revokePairing(PairingRevokeRequest(platform, userId)) } - } - } - - fun clearPending() { - runAction("Clear Pending") { - safeApiCall { ApiClient.hermesApi.clearPendingPairing() } - } - } - - private fun runAction( - actionName: String, - apiCall: suspend () -> NetworkResult, - ) { - _uiState.update { it.copy(isActionRunning = true) } - viewModelScope.launch { - val result = withContext(Dispatchers.IO) { apiCall() } - when (result) { - is NetworkResult.Success -> { - _uiState.update { - it.copy( - isActionRunning = false, - toastMessage = "Pairing action '$actionName' succeeded", - ) - } - loadPairing() - } - - is NetworkResult.Failure -> { - _uiState.update { - it.copy( - isActionRunning = false, - toastMessage = "Action '$actionName' failed: ${result.error.message}", - ) - } - } - } - } - } - - override fun clearToast() { - _uiState.update { it.copy(toastMessage = null) } - } -} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 55015110..53df2166 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -79,7 +79,6 @@ 설정 MCP 서버 모델 - 페어링 키 채널 로그 @@ -400,16 +399,6 @@ 일치하는 구독 없음 검색어를 조정해보세요. - - 기기 페어링 - 승인 대기 - 전체 지우기 - 승인 - 승인된 기기 - 승인된 기기가 없습니다. - 사용자 ID: %1$s - 철회 - 설정 초기화 @@ -683,12 +672,6 @@ 헤르메스 모바일 Hermes Agent 제어판 인증 로그인 - 페어링 로그인 - - - 페어링 로그인 - 아래에 Hermes 대시보드의 페어링 문자열을 붙여넣으세요. - 페어링 코드 모델 제공자 확인 중… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b6d27c9..58c84eba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,7 +92,6 @@ Config MCP Servers Models - Pairing Keys Channels Logs @@ -538,16 +537,6 @@ No matching subscriptions Try adjusting your search. - - Device Pairing - Pending Approvals - Clear All - Approve - Approved Devices - No approved devices paired yet. - User ID: %1$s - Revoke - Configuration Reset @@ -833,12 +822,6 @@ Hermes Mobile Control panel for Hermes Agent Auth Login - Pairing Login - - - Pairing Login - Paste the pairing string from your Hermes dashboard below. - Pairing Code Fetching model providers… diff --git a/app/src/test/java/com/m57/hermescontrol/BottomNavigationSelectionTest.kt b/app/src/test/java/com/m57/hermescontrol/BottomNavigationSelectionTest.kt index 9b0d5e21..e46add54 100644 --- a/app/src/test/java/com/m57/hermescontrol/BottomNavigationSelectionTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/BottomNavigationSelectionTest.kt @@ -46,7 +46,6 @@ class BottomNavigationSelectionTest { SkillsScreen, LandingScreen, AuthLoginScreen, - PairingCodeEntryScreen, ) for (screen in others) { assertEquals( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 508a30bd..938a9b55 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -542,6 +542,15 @@ class HermesWsClientTest { "Gated WS ticket fetch should be attempted even without a bare cookie", connectLatch.await(5, TimeUnit.SECONDS), ) + // The server-side onOpen latch fires a hair before the client receives + // the 101 handshake and WsListenerImpl sets CONNECTED — await the real + // status transition (as every other connect test does) instead of a + // racy read that can observe CONNECTING. + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value) ticketServer.shutdown() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModelTest.kt deleted file mode 100644 index 00ed7270..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModelTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.m57.hermescontrol.ui.pairing - -import android.app.Application -import android.util.Log -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkAll -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test - -class PairingCodeEntryViewModelTest { - private val mockApp: Application = mockk(relaxed = true) - - @Before - fun setup() { - mockkStatic(Log::class) - every { Log.d(any(), any()) } returns 0 - every { Log.e(any(), any(), any()) } returns 0 - } - - @After - fun teardown() { - unmockkAll() - } - - @Test - fun testOnManualCodeChange_updatesStateAndClearsError() { - val viewModel = PairingCodeEntryViewModel(mockApp) - - // Force an error to verify it gets cleared - // using onCodeDetected with invalid input to set the error - viewModel.onCodeDetected("invalid-code") - assertEquals(true, viewModel.uiState.value.errorMessage != null) - - viewModel.onManualCodeChange("new-code") - - val state = viewModel.uiState.value - assertEquals("new-code", state.manualCode) - assertNull(state.errorMessage) - } - - @Test - fun testOnManualCodeChange_trimsInput() { - val viewModel = PairingCodeEntryViewModel(mockApp) - - viewModel.onManualCodeChange(" trimmed-code \n") - - val state = viewModel.uiState.value - assertEquals("trimmed-code", state.manualCode) - } -}