refactor: remove device Pairing feature entirely (issue #645) (#650)

* refactor: remove device Pairing feature entirely (issue #645)

- Delete Pairing.kt model + pairing API endpoints (get/approve/revoke/clear)
- Delete PairingScreen/PairingViewModel + PairingCodeEntryScreen/ViewModel
- Remove NavKeys, ScreenRegistry drawer entry, Navigation.kt entry + Landing pairing button
- Remove pairing string resources (en + ko); keep connect_* config-parse strings
- Keep Telegram onboarding pairing_id flow (separate concept, per issue)
- Fix related doc comments + remaining test reference

* test: fix racy CONNECTED read in gated-mode WS test (issue #635 test)

testGatedMode_attemptsTicketFetchWithoutBareCookie asserted CONNECTED
immediately after the server-side onOpen latch fired, racing the client
handshake (WsListenerImpl sets CONNECTED on receipt of the 101). Await the
real status transition via connectionStatus.first{==CONNECTED} like the
other connect tests do. No product behavior changed.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-20 23:23:29 +03:00
1 parent e44a53366b
commit a62bc7e16c
19 files changed
+13 -892

No files matched your search

@@ -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<PairingCodeEntryScreen> {
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() } }
@@ -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
@@ -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,
@@ -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
@@ -1,30 +0,0 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class PairingResponse(
val pending: List<PairingItem>,
val approved: List<PairingItem>,
)
@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,
)
@@ -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<List<RecentUnlock>>
@GET("api/pairing")
suspend fun getPairing(): Response<PairingResponse>
@POST("api/pairing/approve")
suspend fun approvePairing(
@Body body: PairingApproveRequest,
): Response<Unit>
@POST("api/pairing/revoke")
suspend fun revokePairing(
@Body body: PairingRevokeRequest,
): Response<Unit>
@POST("api/pairing/clear-pending")
suspend fun clearPendingPairing(): Response<Unit>
@GET("api/config/raw")
suspend fun getRawConfig(): Response<RawConfigResponse>
@@ -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].
*/
@@ -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.
/**
@@ -167,7 +167,6 @@ fun ConnectScreen(
}
}
// Pairing (primary path)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -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,
)
}
}
}
}
@@ -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))
}
}
}
}
}
@@ -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<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 = 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 <T : ViewModel> create(modelClass: Class<T>): T = PairingCodeEntryViewModel(app) as T
}
@@ -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))
}
}
}
}
}
}
}
}
}
}
}
}
@@ -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<PairingUiState> = _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<Unit>,
) {
_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) }
}
}