refactor(connections): unify connection settings — one screen, one card

The app carried three generations of "how to manage a connection" stacked:
a pre-multi-connection singular `ConnectionSettings` (1429 lines) reached
from an "Active Connection" quick-look card on Settings; a multi-aware
plural `ConnectionsSettings` card list reached from a separately-named
"Connections" category row; and a consolidated `AgentInfoSheet` doing
quick-switch. Two screens with near-identical names, two entry points
from Settings, overlapping functionality — the user had no way to
predict which "Connection" tap would land where.

Collapsed everything into one mental model:

    Settings
    ├── Active Agent card       (unchanged)
    ├── Inspect Agent card      (unchanged)
    └── [Connections] row       (the ONLY connection entry from Settings)
        └── ConnectionsSettings subpage
            ├── Non-active card     (flat: title + subtitle + actions)
            └── Active card         (flat + inline deep body)
                ├── Status section  (API / Relay / Session → info sheets)
                ├── Endpoints expander
                ├── Advanced expander
                │   ├── Manual URL  (API + Relay URL + Save & Test)
                │   ├── Insecure toggle (with Ack dialog)
                │   └── Manual code (3-step fallback)
                └── Security posture (transport + Tailscale + HW + Paired Devices)

What moved:

  - NEW `ui/components/ActiveConnectionSections.kt` (~650 lines) owns the
    three active-card-only body sections plus the `ManualPairStep` helper
    lifted from the deleted legacy screen.
  - `ui/screens/ConnectionsSettingsScreen.kt` rewritten to render the
    full active-card body inline via the new sections. Screen-scope
    hoisting for info sheets + the insecure Ack dialog so LazyColumn
    item disposal mid-scroll can't silently dismiss them.
  - `ui/screens/ConnectionSettingsScreen.kt` DELETED (was 1429 lines).
  - `ui/RelayApp.kt` drops the composable block for the deleted route,
    the `data object ConnectionSettings` entry in the Screen sealed
    class, and the onNavigateToConnectionSettings lambda. Adds
    onNavigateToPairedDevices to the surviving screen's composable call.
  - `ui/screens/SettingsScreen.kt` drops the Active Connection
    quick-look Card (~90 lines), the onNavigateToConnectionSettings
    param, and the 7 collectAsState calls that were only used by that
    card (apiReachable / apiHealth / authState / apiUrl / relayUrl /
    relayUiState / relayRowState + relayFeatureEnabled).
  - user-docs: every `Settings → Connection → X` nav path updated to
    `Settings → Connections → [active card] → X` or
    `...→ Advanced → X`. Stale "Connection chip in the Chat top bar"
    copy rewritten to point at the AgentInfoSheet switcher (the chip
    was removed in the 2026-04-20 inline-switcher refactor).

Subtle design calls flagged in the DEVLOG:
  - LazyColumn item disposal vs. modal state → screen-scope hoisting
    for sheets + Ack dialog; card-scope only for modals that can't
    logically exist cross-card (rename / revoke / remove confirms).
  - Endpoint-flow cold-start gap → outer `if (isActive && VM != null)`
    gates the entire deep body; inner `if (endpoints.isNotEmpty())`
    only gates the Endpoints expander, so Status + Advanced + posture
    are unconditionally visible.
  - Duplicate `reconnectIfStale()` on Settings + ConnectionsSettings
    entry is intentional — the VM no-ops if already in flight, and
    firing on Settings entry means the subpage arrives warm.

Team delivery: three parallel feature-dev:code-explorer agents produced
the full feature inventory, the integration map, and the caller trace
in under 2 minutes. Made the synthesis + implementation mechanical.
Compiles clean on both googlePlay and sideload flavors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-04-21 19:56:38 -04:00
co-authored by Claude Sonnet 4.6
parent 884470979c
commit 5a38b69067
9 changed files with 1278 additions and 1775 deletions
+4
View File
@@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **`relayReady` signal gates voice + bridge surfaces.** New `ConnectionViewModel.relayReady: StateFlow<Boolean>` composes three inputs — WSS `ConnectionState.Connected`, `AuthState.Paired`, AND non-blank `relayUrl` — into a single "WSS is actually functional" truth. ChatScreen's mic button dims + Toasts "Voice mode unavailable — relay not connected" instead of launching an overlay that would immediately fail on `/voice/transcribe`. BridgeScreen surfaces an error-container banner at the top of the scroll region so the user doesn't enable the master toggle expecting commands to flow. Soft-gate semantics — neither surface hard-disables, matching the existing Chat-send / Terminal-Refresh patterns; BridgeScreen intentionally still lets the user pre-configure permissions and safety rails before a relay pairs. Three-input (rather than the simpler two-input `chatReady` form) because the Case-C teardown edge — last connection removed, `_apiServerUrl`/`_relayUrl` blanked — can leave a stale `Paired` token alive alongside a dead URL; without the URL check the banner would never surface in that state.
### Changed
- **Connection settings unified — one screen, one mental model.** The pre-refactor app had two near-identically-named screens (`ConnectionSettings` singular, `ConnectionsSettings` plural) reached from two different Settings-top surfaces (Active Connection quick-look card vs. "Connections" category row), each covering overlapping functionality. Everything the singular screen did — pair QR entry, manual URL config, insecure toggle, manual pairing code fallback, 3 tappable status rows — now folds inline onto the **active card** of the plural screen as expandable body sections. The singular `ConnectionSettings` screen (1429 lines), its route, its `Screen` enum entry, its `onNavigateToConnectionSettings` param chain, and the Active Connection quick-look card on Settings have all been removed. New active-card structure: Status rows (always visible) → Endpoints expander → Advanced expander (manual URL / insecure toggle / manual pairing code) → Security posture strip (transport badge + Tailscale chip + hardware keystore badge + Paired Devices row). Non-active cards stay flat. Navigation path throughout the user docs updates from `Settings → Connection → X` to `Settings → Connections → [active card] → X` (or `→ Advanced → X`). New file `ui/components/ActiveConnectionSections.kt` (~650 lines) owns the active-card bodies; `ui/screens/ConnectionsSettingsScreen.kt` is rewritten (~580 lines) with screen-scope hoisting for info sheets + the insecure-Ack dialog so `LazyColumn` item disposal can't silently dismiss them mid-scroll. Team-delivered: three parallel `feature-dev:code-explorer` agents produced the full feature inventory + integration map + caller trace in under 2 minutes, which made the synthesis + implementation mechanical.
### Fixed
- **Voice-exit chime firing on every Add-connection tap.** `ConnectionSwitchCoordinator.switchConnection` fires the `voiceStopCallback` unconditionally at step 3 (correct for connection-to-connection switches while voice is active), but `beginAddConnection` also routes through `switchConnection` to bind the placeholder Connection's auth store before the pair wizard runs — and `VoiceViewModel.exitVoiceMode()` was playing `sfxPlayer.playExit()` regardless of whether voice mode was actually on. Logcat confirmed the chime on every Add-connection FAB tap. Fix adds an idempotence guard at the top of `exitVoiceMode()`: early-return when `_uiState.value.voiceMode` is already false. Teardown is still safe to skip because every inner statement is null-guarded + try/catch-wrapped and would be a no-op on an already-stopped voice session; the only meaningful line is the `playExit()` SFX, which is what we're silencing.
+54
View File
@@ -1,5 +1,59 @@
# Hermes-Relay — Dev Log
## 2026-04-21 — Connection-settings unification: kill the singular screen, active card owns everything
**Context.** Pre-ship scan for v0.7.0 surfaced a UX fault line that had been accruing since v0.3: the app had *two* screens with nearly identical names (`ConnectionSettings` singular, `ConnectionsSettings` plural) reached from *two* different Settings-top surfaces (Active Connection quick-look card vs. "Connections" category row), covering *overlapping* surfaces of functionality. Users hitting "Active Connection" from Settings landed on a 1429-line detail screen with pair / manual URL / TLS / manual pairing code; users hitting "Connections" landed on a 564-line card list with rename / re-pair / revoke / remove per card. Both said "Active" somewhere; both claimed to be the authoritative connection home. Neither was.
**Design.** One screen, one mental model. The plural `ConnectionsSettings` screen stays — it's the multi-connection-aware home and structurally correct. The singular `ConnectionSettings` (and its route, and its `onNavigateToConnectionSettings` param chain, and the Active Connection quick-look card on Settings that led to it) all delete. Everything the singular screen *did* — pair QR entry, manual URL config, insecure toggle, manual pairing code fallback, status rows with tap-for-info-sheet — folds into the **active card** on the plural screen as expandable body sections:
1. **Status section** — 3 tappable rows (API / Relay / Session), always visible on the active card. Replaces the Settings-top quick-look card verbatim. Tap → same info sheets. Relay-row tap while Stale → immediate reconnect + toast.
2. **Endpoints expander** — unchanged from before; just repositioned below the status rows.
3. **Advanced expander** — manual URL config (API + relay), insecure toggle with Ack dialog, manual pairing code fallback (full 3-step flow with 15s auth watcher + snackbar). Collapsed by default — the canonical path is the per-card "Re-pair" button above.
4. **Security posture strip** — transport badge, Tailscale chip, hardware keystore badge, Paired Devices row. Always visible on the active card.
Non-active cards stay flat — just title + subtitle + action row. List density is preserved.
**Where the code landed.**
- `ui/components/ActiveConnectionSections.kt` (new, ~650 lines) — owns the three active-card-only bodies (`ActiveCardStatusSection`, `ActiveCardAdvancedSection` with three private subsections, `ActiveCardSecurityPosture`) plus the `ManualPairStep` helper lifted from the deleted legacy screen.
- `ui/screens/ConnectionsSettingsScreen.kt` (rewritten, ~580 lines) — now renders the full active-card body inline via the new sections, with screen-scope hoisting for info sheets + insecure-Ack dialog (so `LazyColumn` disposing the card mid-scroll can't silently dismiss an open sheet).
- `ui/screens/ConnectionSettingsScreen.kt` (deleted, was 1429 lines).
- `ui/RelayApp.kt` — drops the `composable(Screen.ConnectionSettings.route)` block, the `data object ConnectionSettings` entry in the `Screen` sealed class, and the `onNavigateToConnectionSettings` lambda wired into `SettingsScreen`. Adds `onNavigateToPairedDevices` to the plural screen's composable call.
- `ui/screens/SettingsScreen.kt` — deletes the Active Connection quick-look Card block (~90 lines), the `onNavigateToConnectionSettings` param, and the 7 `collectAsState` calls (apiReachable / apiHealth / authState / apiUrl / relayUrl / relayUiState / relayRowState + `relayFeatureEnabled`) that were only used by that card.
- `user-docs/reference/configuration.md` + `user-docs/guide/getting-started.md` — nav paths updated throughout: every `Settings → Connection → X` becomes `Settings → Connections → [active card] → X` or `...→ Advanced → X`.
**Subtle decisions worth flagging.**
*LazyColumn item disposal vs. modal state.* The Card is a `LazyColumn` item. If the user scrolls it off-screen while a status-row info sheet is open, the item gets disposed and any `remember { mutableStateOf(false) }` inside it is gone. So `showApiInfoSheet` / `showRelayInfoSheet` / `showSessionInfoSheet` / `showInsecureAckDialog` are all hoisted to `ConnectionsSettingsScreen` scope. Dialog confirmation still wipes card-scope state if the card is still alive; screen scope survives scroll regardless. Card-scope `remember` is retained only for per-card modals that logically can't exist cross-card (rename / revoke-confirm / remove-confirm dialogs).
*Endpoint-flow cold-start gap.* `observeDeviceEndpoints()` is a cold `flow { ... }` that suspends on `getOrCreateDeviceId()` before the first emission. During that gap, `endpoints == emptyList()` and the Endpoints expander hides — which is correct for the Endpoints-only content but NOT for the Status section or Advanced section, which should be unconditionally visible on the active card. I split the guard: outer `if (isActive && activeConnectionViewModel != null)` gates the deep body; the inner `if (endpoints.isNotEmpty())` only gates the Endpoints expander. Flagged by the code-explorer agent's "one thing to warn a new developer about" — worth documenting.
*Why `InsecureConnectionAckDialog` is hoisted through a callback rather than owned by the Advanced section.* The dialog would work if owned by the card — but if the card scrolls off mid-open, the dialog dismisses silently. The Advanced subsection fires `onInsecureAckRequested()` which opens a screen-scope boolean; the dialog renders in the screen's root composition. Survives scroll, survives recomposition, one source of truth.
*Mutex-free reconnect-on-entry.* Both `SettingsScreen` and `ConnectionsSettingsScreen` call `reconnectIfStale()` in `LaunchedEffect(Unit)`. The VM method no-ops if a reconnect is already in flight, so the duplicate call is free and actually helpful — firing on Settings entry means the subpage arrival already has a warm reconnect attempt rather than triggering one on its own arrival.
**Team delivery.** Spawned three parallel `feature-dev:code-explorer` agents up front — one to inventory the 1429-line singular screen's features by category (inline / advanced / duplicate / dead code / state deps / dialogs / nav entry points), one to map the plural screen's current active-card rendering + expansion patterns + constraints, one to trace every caller of the route and parameter chains that would need rewiring. All three returned line-numbered reports in under 2 minutes, which made the synthesis + implementation step mechanical. Worth the pattern for any similar "delete-and-fold" refactor.
**Post-refactor vertical map for connection management:**
```
Settings
├── Active Agent card (unchanged — summary chip, opens AgentInfoSheet inline)
├── Inspect Agent card (unchanged — Profile deep-link)
└── [Connections] category row
└── ConnectionsSettings subpage
├── Non-active card (title + subtitle + action row — flat)
└── Active card (everything above, PLUS inline deep body:)
├── Status section (API / Relay / Session rows → info sheets)
├── Endpoints expander (conditional on endpoints.isNotEmpty())
├── Advanced expander
│ ├── Manual URL (API + Relay URL + Save & Test)
│ ├── Insecure toggle (with first-enable Ack dialog)
│ └── Manual code (3-step fallback flow)
└── Security posture (transport + Tailscale + hardware + Paired Devices row)
```
Two top-level entries. One subpage. One active card. Every connection action reachable in a deterministic drill-down. No naming collisions, no duplicate surfaces, no wondering which "Connection" the Settings tap will land on.
## 2026-04-21 — `relayReady` gate + KDoc nested-comment trap
**Context.** Two unrelated passes in one session. (1) Voice mode and Bridge commands both depend on the WSS relay, but the app had no unified signal for "relay is actually functional" — a user with no relay paired would tap the Mic and get a cryptic failure, or the Bridge master toggle would happily enable an accessibility service whose commands would never arrive. (2) While running a ship-readiness scan for v0.7.0, the compile failed with "Unresolved reference 'isReady'" at `MainActivity.kt:67` — a symptom that took some digging to trace to its real cause.
@@ -90,7 +90,6 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.ConnectionSettingsScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
@@ -212,7 +211,10 @@ sealed class Screen(
// === END PHASE3-safety-rails ===
// Per-category settings sub-screens — split out of the mega SettingsScreen
// following the VoiceSettingsScreen pattern (see DEVLOG 2026-04-11).
data object ConnectionSettings : Screen("settings/connection", "Connection", Icons.Filled.Settings)
// (The singular `ConnectionSettings` object was removed on 2026-04-21
// when its underlying screen was collapsed into the active card of
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
@@ -944,9 +946,6 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToConnectionSettings = {
navController.navigate(Screen.ConnectionSettings.route)
},
onNavigateToChatSettings = {
navController.navigate(Screen.ChatSettings.route)
},
@@ -1019,25 +1018,19 @@ fun RelayApp() {
}
)
}
composable(Screen.ConnectionSettings.route) {
ConnectionSettingsScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onNavigateToPairedDevices = {
navController.navigate(Screen.PairedDevices.route)
},
onNavigateToPair = {
// Pre-create + switch to the placeholder so
// applyPairingPayload's token write lands in
// the new connection's store. See
// ConnectionViewModel.beginAddConnection kdoc.
connectionSwitchScope.launch {
val id = connectionViewModel.beginAddConnection()
navController.navigate(Screen.Pair.route(id))
}
}
)
}
// (The `composable(Screen.ConnectionSettings.route)` block
// that used to live here — hosting the singular, legacy
// 1400-line `ConnectionSettingsScreen` — was removed on
// 2026-04-21 as part of the connection-settings
// unification. Everything that screen did (pair, manual
// URL config, TLS/insecure toggle, manual pairing code
// fallback) now lives inline on the active card of the
// plural `ConnectionsSettings` screen below, via the
// expandable sections in `ActiveConnectionSections.kt`.
// The corresponding `data object ConnectionSettings` was
// also removed from the `Screen` sealed class, and the
// `onNavigateToConnectionSettings` param on
// `SettingsScreen` was dropped.)
composable(Screen.ConnectionsSettings.route) {
val connectionsList by connectionViewModel.connections.collectAsState()
val activeId by connectionViewModel.activeConnectionId.collectAsState()
@@ -1114,9 +1107,15 @@ fun RelayApp() {
}
},
onBack = { navController.popBackStack() },
onNavigateToPairedDevices = {
navController.navigate(Screen.PairedDevices.route)
},
// Pass the VM so the active card can render the
// shared EndpointsCard inline — matches the info
// density of Settings → Connection's Card 1.5.
// shared EndpointsCard inline AND the unified
// Advanced section (manual URL / insecure toggle /
// manual pairing code). Null-safe — if the VM
// isn't wired (tests, previews), the active card
// degrades to the flat layout.
connectionViewModel = connectionViewModel,
)
}
@@ -0,0 +1,855 @@
package com.hermesandroid.relay.ui.components
import android.content.ClipData
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.asBadgeState
import com.hermesandroid.relay.viewmodel.statusText
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* ──────────────────────────────────────────────────────────────────────
* Active-connection card body sections — the "what was Settings →
* Connection" feature set, now living inline on the active `ConnectionCard`
* inside [com.hermesandroid.relay.ui.screens.ConnectionsSettingsScreen].
*
* The per-card action row (Reconnect/Rename/Re-pair/Revoke/Remove) stays
* on `ConnectionCard` itself. Everything below the first divider — status
* rows, endpoints expander, advanced expander (manual URL, insecure
* toggle, manual pairing code), and the security-posture strip — is
* extracted here so `ConnectionsSettingsScreen` stays focused on the list
* layout and this file owns the active-card deep content.
*
* All composables in this file assume they render INSIDE an active
* `ConnectionCard`'s Column (16dp padding, 8dp vertical spacing). None of
* them introduce a new Card wrapper or scroll container.
*
* Call sites pass a non-null `connectionViewModel` — these sections are
* never rendered for non-active cards, so the VM guard happens at the
* call site.
* ──────────────────────────────────────────────────────────────────────
*/
/**
* Three tappable status rows (API / Relay / Session), always visible on
* the active card. Replaces the old "Active Connection" quick-look card
* that used to live at the top of `SettingsScreen` — same information
* density, same tap-for-info-sheet behavior.
*
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
* reconnect + toast; every other row falls through to the info sheet
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
*/
@Composable
fun ActiveCardStatusSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
onOpenApiInfo: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val context = LocalContext.current
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
ConnectionStatusRow(
label = "API Server",
isConnected = apiReachable,
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
statusText = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
apiReachable -> "Reachable"
else -> "Unreachable"
},
onClick = onOpenApiInfo,
modifier = Modifier.fillMaxWidth(),
)
if (relayEnabled) {
// ADR 24: relayRowState carries both the phase and the active
// endpoint role. statusText appends " · <Role>" when the
// resolver has picked one, so the chip reads "Connected · LAN"
// etc. without any extra wiring here.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
Toast.LENGTH_SHORT,
).show()
} else {
onOpenRelayInfo()
}
},
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* Advanced expandable section — three subsections:
* - Manual URL configuration (API URL + key + Save & Test,
* Relay URL + Save & Test + Disconnect)
* - Allow-insecure-connections toggle (with first-enable Ack dialog)
* - Manual pairing code fallback (3-step flow with in-flight Connect
* watcher + snackbar feedback)
*
* Wrapped in a single `SettingsExpandableCard` so the user can collapse
* the entire block — none of it is needed for the common paired-via-QR
* flow. Expanded-state is `rememberSaveable` so rotation / process death
* preserves user intent.
*
* [onInsecureAckRequested] opens the `InsecureConnectionAckDialog` at
* screen scope; this composable never owns it directly so the dialog
* can persist through card recomposition in a LazyColumn.
*/
@Composable
fun ActiveCardAdvancedSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
isDarkTheme: Boolean,
onInsecureAckRequested: () -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
SettingsExpandableCard(
title = "Advanced",
expanded = expanded,
onToggle = { expanded = !expanded },
isDarkTheme = isDarkTheme,
) {
ManualUrlSubsection(
connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
)
if (relayEnabled) {
HorizontalDivider()
InsecureToggleSubsection(
connectionViewModel = connectionViewModel,
onInsecureAckRequested = onInsecureAckRequested,
)
HorizontalDivider()
ManualPairingCodeSubsection(
connectionViewModel = connectionViewModel,
)
}
}
}
/**
* Manual URL configuration subsection. Power-user only — the canonical
* path is QR pair via the Re-pair button on the card row above.
*
* Kept internal rather than split further because the API + relay
* fields share the `isTesting` + `Save & Test` idiom and the two test
* paths talk to the same VM.
*/
@Composable
private fun ManualUrlSubsection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
) {
val context = LocalContext.current
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val apiKeyPresent by connectionViewModel.authManager.apiKeyPresent.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
// Keyed on the backing URL so a connection switch refreshes the input.
var apiUrlInput by remember(apiServerUrl) { mutableStateOf(apiServerUrl) }
var apiKeyInput by remember { mutableStateOf("") }
var apiKeyVisible by remember { mutableStateOf(false) }
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
var isTestingApi by remember { mutableStateOf(false) }
OutlinedTextField(
value = apiUrlInput,
onValueChange = { apiUrlInput = it },
label = { Text("API Server URL") },
placeholder = { Text("http://your-server:8642") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = apiKeyInput,
onValueChange = { apiKeyInput = it },
label = { Text("API Key (optional)") },
placeholder = {
Text(
if (apiKeyPresent) "•••• already set (leave blank to keep)"
else "Leave empty if not configured",
)
},
supportingText = {
Text(
if (apiKeyPresent && apiKeyInput.isBlank()) {
"A key is already stored — leave blank to keep it, or type to replace"
} else {
"Only needed if Hermes is configured with API_SERVER_KEY"
},
)
},
singleLine = true,
visualTransformation = if (apiKeyVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
Icon(
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
else Icons.Filled.Visibility,
contentDescription = if (apiKeyVisible) "Hide" else "Show",
)
}
},
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
connectionViewModel.updateApiServerUrl(apiUrlInput)
if (apiKeyInput.isNotBlank()) {
connectionViewModel.updateApiKey(apiKeyInput)
}
isTestingApi = true
connectionViewModel.testApiConnection { success ->
isTestingApi = false
Toast.makeText(
context,
if (success) "API server reachable" else "Cannot reach API server",
Toast.LENGTH_SHORT,
).show()
}
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
) {
Text("Save & Test")
}
if (isTestingApi) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
}
if (relayEnabled) {
HorizontalDivider()
OutlinedTextField(
value = relayUrlInput,
onValueChange = {
relayUrlInput = it
// Stale reachability results belong to the prior URL.
connectionViewModel.clearRelayReachableResult()
},
label = { Text("Relay URL") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Save & Test + Disconnect. Connect button intentionally absent
// — it used to cause an unpaired-auth-then-rate-limited trap.
// /health probe with no WSS handshake is the safe surface.
val relayReachable by connectionViewModel.relayReachableResult.collectAsState()
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { connectionViewModel.testRelayReachable(relayUrlInput) },
enabled = relayUrlInput.isNotBlank() &&
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
) {
Text("Save & Test")
}
OutlinedButton(
onClick = { connectionViewModel.disconnectRelay() },
enabled = relayConnectionState != ConnectionState.Disconnected,
) {
Text("Disconnect")
}
}
when (val r = relayReachable) {
is ConnectionViewModel.RelayReachable.Probing -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Text(
text = "Probing /health…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is ConnectionViewModel.RelayReachable.Ok -> {
Text(
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4CAF50),
)
}
is ConnectionViewModel.RelayReachable.Fail -> {
val humanErr = classifyError(
Exception(r.message),
context = "save_and_test",
)
Column {
Text(
text = "✗ ${humanErr.title}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Text(
text = humanErr.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
null -> { /* idle */ }
}
}
}
/**
* Insecure-mode toggle subsection. First enable triggers the
* [InsecureConnectionAckDialog] at screen scope via
* [onInsecureAckRequested]; subsequent toggles fire through the VM
* directly.
*/
@Composable
private fun InsecureToggleSubsection(
connectionViewModel: ConnectionViewModel,
onInsecureAckRequested: () -> Unit,
) {
val insecureMode by connectionViewModel.insecureMode.collectAsState()
val insecureAckSeen by connectionViewModel.insecureAckSeen.collectAsState()
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Text(
text = "Insecure connection — traffic is not encrypted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Allow insecure connections",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Enable ws:// and http:// for local dev/testing only",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = insecureMode,
onCheckedChange = { enabled ->
if (enabled && !insecureAckSeen) {
// First enable → open threat-model Ack dialog at
// screen scope. VM is written only on confirm.
onInsecureAckRequested()
} else {
connectionViewModel.setInsecureMode(enabled)
}
},
)
}
}
/**
* Manual pairing code subsection — the 3-step fallback flow for when
* the QR scanner isn't usable (no camera, headless host, bad lighting).
*
* 1. Copy the phone-generated code (with Refresh to regenerate)
* 2. Run `hermes-pair --register-code <code>` on the host
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
* failure through the global snackbar host
*
* Mirrors the legacy `ConnectionSettingsScreen` Card 3 flow verbatim
* since it's already battle-tested. The top-level "Connect" button on
* this card requires a relay URL — if the user hasn't set one in the
* Manual URL subsection above, the button is disabled and an inline
* hint points them there.
*/
@Composable
private fun ManualPairingCodeSubsection(
connectionViewModel: ConnectionViewModel,
) {
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val snackbarHost = LocalSnackbarHost.current
// Connect state + auth-watcher attempt counter. Keyed counter so
// retrying cancels any in-flight watcher and restarts with a fresh
// 15s budget — matches the legacy Card 3 behavior byte-for-byte.
var connectInProgress by remember { mutableStateOf(false) }
var connectAttempt by remember { mutableStateOf(0) }
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(connectAttempt) {
if (connectAttempt == 0) return@LaunchedEffect
try {
val terminal = kotlinx.coroutines.withTimeout(15_000) {
connectionViewModel.authState
.first { it is AuthState.Paired || it is AuthState.Failed }
}
connectInProgress = false
when (terminal) {
is AuthState.Paired -> snackbarHost.showSnackbar("Paired successfully")
is AuthState.Failed -> {
val human = classifyError(
IllegalStateException(terminal.reason),
context = "pair",
)
snackbarHost.showHumanError(human)
}
else -> Unit
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
connectInProgress = false
val human = classifyError(
java.io.IOException("No response from relay"),
context = "pair",
)
snackbarHost.showHumanError(human)
} catch (e: Exception) {
connectInProgress = false
snackbarHost.showHumanError(classifyError(e, context = "pair"))
}
}
Text(
text = "Manual pairing code (fallback)",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "Use this when you can't scan the pairing QR. " +
"Follow the three steps — they're meant to be done in order on " +
"whatever machine you have shell access to.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Step 1 — display + copy + regenerate
ManualPairStep(number = 1, title = "Copy the code below") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = pairingCode,
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
)
snackbarHost.showSnackbar("Pairing code copied")
}
}) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy pairing code",
)
}
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Generate new code",
)
}
}
}
// Step 2 — host command
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = "hermes-pair --register-code $pairingCode",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {
val cmd = "hermes-pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
)
snackbarHost.showSnackbar("Command copied")
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy hermes-pair command",
modifier = Modifier.size(16.dp),
)
}
}
}
}
// Step 3 — Connect button + relay-URL prerequisite check
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
val canConnect = !connectInProgress &&
relayUrl.isNotBlank() &&
pairingCode.isNotBlank()
Button(
onClick = {
connectInProgress = true
connectAttempt += 1
// Atomic apply-code-and-reset avoids races between the
// stale session's code-regeneration and this fresh
// authenticate()'s mirror-write. Then kick a disconnect
// + connect so the WSS handshake uses the new code.
connectionViewModel.authManager
.applyServerIssuedCodeAndReset(pairingCode)
connectionViewModel.disconnectRelay()
connectionViewModel.connectRelay(relayUrl)
},
enabled = canConnect,
modifier = Modifier.fillMaxWidth(),
) {
if (connectInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.size(8.dp))
Text("Connecting…")
} else {
Text("Connect")
}
}
if (relayUrl.isBlank()) {
Text(
text = "Relay URL not set — open the Manual URL section above to set it first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
HorizontalDivider()
TextButton(
onClick = { explainerExpanded = !explainerExpanded },
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
style = MaterialTheme.typography.bodySmall,
)
}
if (explainerExpanded) {
Text(
text = "This is a fallback for when you can't scan the pairing QR " +
"— for example, no camera, the host can't render a QR, or you " +
"only have SSH access from a single device. The canonical flow " +
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
"How it works: the phone generates a 6-character code locally. " +
"You paste that code into the host's `hermes-pair --register-code` " +
"command, which pre-registers it with the relay. When you tap " +
"Connect here, the phone presents the same code to the relay " +
"and gets a long-lived session token in return.\n\n" +
"Bridge / device-control is gated by the master toggle on the " +
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
"the relay session.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Security posture strip — always visible on the active card, directly
* below the Advanced expander. Renders, in order:
* - Transport security badge (wss:// vs ws://)
* - Tailscale detected chip (conditional)
* - Hardware keystore badge (conditional)
* - Paired Devices row (always — tap to navigate)
*
* These are the "what's my pairing posture?" facts. Short + info-dense,
* so they don't live behind an expander.
*/
@Composable
fun ActiveCardSecurityPosture(
connectionViewModel: ConnectionViewModel,
onNavigateToPairedDevices: () -> Unit,
) {
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val insecureReason by connectionViewModel.insecureReason.collectAsState()
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
TransportSecurityBadge(
isSecure = isUrlSecure(relayUrl),
reason = insecureReason.ifBlank { null },
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
if (isTailscaleDetected) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
tint = Color(0xFF2E7D32),
modifier = Modifier.size(16.dp),
)
Text(
text = "Tailscale detected",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF2E7D32),
)
}
}
if (currentPairedSession?.hasHardwareStorage == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Text(
text = "Session token stored in hardware keystore",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onNavigateToPairedDevices() }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Paired Devices",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = if (pairedDevices.isNotEmpty()) "${pairedDevices.size} active"
else "View and revoke paired phones",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Numbered step row for the Manual pairing code fallback. Tightly
* coupled to its Card 3 layout — step badge sizing + content shape —
* so it stays private-ish here rather than promoted to a shared
* component. Lift to `ui.components` if a second caller appears.
*/
@Composable
private fun ManualPairStep(
number: Int,
title: String,
content: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Surface(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(percent = 50),
modifier = Modifier.size(24.dp),
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize(),
) {
Text(
text = number.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
content()
}
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -34,6 +35,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,12 +44,21 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
import com.hermesandroid.relay.ui.components.ActiveCardSecurityPosture
import com.hermesandroid.relay.ui.components.ActiveCardStatusSection
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
import com.hermesandroid.relay.ui.components.EndpointsCard
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
import com.hermesandroid.relay.ui.components.RelayInfoSheet
import com.hermesandroid.relay.ui.components.SessionInfoSheet
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
@@ -55,17 +66,30 @@ import java.util.concurrent.TimeUnit
/**
* Full-screen manager for the list of Hermes connections. Reachable via
* Settings → Connections and via the "Manage connections…" button in the
* connection switcher bottom sheet.
* Settings → Connections — the single authoritative home for everything
* connection-related. Replaces the older (deleted) singular
* `ConnectionSettingsScreen` that used to own pair / manual-URL / insecure
* toggle / manual-pairing-code; all of that now lives on the active card
* below under expandable sections.
*
* Each connection is a card with:
* - Label (tappable → rename dialog)
* - Hostname + paired status subtitle
* - Re-pair / Revoke / Remove actions
* - "Active" badge + tonal highlight on the currently-active connection
* Cards, top to bottom inside the `LazyColumn`:
* - **Non-active connection cards** — label + status badge subtitle +
* per-card action row (Reconnect/Rename/Re-pair/Revoke/Remove). Flat,
* no expanders, no deep content. Users switch to the card to make it
* active before drilling in.
* - **The active connection card** — everything above, plus an inline
* body with three zones:
* 1. Status rows (API / Relay / Session) — always visible; tap opens
* the matching info sheet.
* 2. Endpoints expander (when the pairing carries endpoints).
* 3. Advanced expander — manual URL config, insecure toggle, manual
* pairing code fallback (the full 3-step flow).
* 4. Security posture strip (transport badge, Tailscale chip,
* hardware keystore badge, Paired Devices row) — always visible.
*
* An Extended FAB pinned bottom-right launches the add-connection pairing
* flow.
* The Extended FAB launches the Add-connection pairing wizard via
* [onAddConnection] (which in `RelayApp` pre-creates a placeholder and
* navigates to `Screen.Pair` with `autoStart = "scan"`).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -84,13 +108,41 @@ fun ConnectionsSettingsScreen(
onRemoveConnection: (id: String) -> Unit,
onAddConnection: () -> Unit,
onBack: () -> Unit,
// ADR 24 — the active connection's endpoint state. Passed in so the
// active card's inline-expand reveals the same EndpointsCard the
// Settings → Connection screen renders (role/probe/prefer chips) instead
// of a stale "paired X hours ago" stub. `null` = no VM wired, which the
// card treats as "legacy pairing, no endpoint list available".
// Opens `PairedDevicesScreen` for the server-side session list. Wired
// via the "Paired Devices" row inside the active card's security
// posture strip. Must not be null — the row is always rendered.
onNavigateToPairedDevices: () -> Unit,
// ADR 24 — the active connection's endpoint state is read through this
// VM. `null` means no VM wired (test harness / @Preview); the active
// card falls back to legacy behavior and hides all deep content.
connectionViewModel: ConnectionViewModel? = null,
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
// Feature flag for the entire relay surface (WSS, voice, bridge).
// When off, Status section hides the Relay + Session rows and the
// Advanced section hides relay-specific subsections. ChatReady path
// (HTTP-only) is unaffected.
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
// Kick a WSS reconnect on screen entry in case the user landed here
// from a Stale chip. Moved here from the deleted singular
// ConnectionSettingsScreen — same intent, same implementation.
LaunchedEffect(Unit) {
connectionViewModel?.reconnectIfStale()
}
// Info-sheet + ack-dialog visibility is hoisted to screen scope so
// the composables survive when the owning card scrolls out of the
// LazyColumn viewport (items can be disposed). Card-level booleans
// would dismiss silently under scroll.
var showSessionInfoSheet by remember { mutableStateOf(false) }
var showApiInfoSheet by remember { mutableStateOf(false) }
var showRelayInfoSheet by remember { mutableStateOf(false) }
var showInsecureAckDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
@@ -122,21 +174,23 @@ fun ConnectionsSettingsScreen(
},
) { innerPadding ->
if (connections.isEmpty()) {
// Empty state — the FAB is the only useful action. Shown in
// practice only during tests / after a wipe; cold start seeds
// a default connection, so the list is rarely ever truly empty.
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
.padding(horizontal = 24.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "No connections yet",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Pair with a Hermes server to add your first connection.",
text = "Tap Add connection to pair with a Hermes server.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -157,22 +211,25 @@ fun ConnectionsSettingsScreen(
ConnectionCard(
connection = connection,
isActive = isActive,
// Only the active card gets a live state; others
// fall back to the persistent pairedAt timestamp
// since we don't track WSS state for background
// connections.
// Live state only meaningful for the active card;
// non-active cards render pairedAt timestamp.
liveState = if (isActive) activeRelayUiState else null,
// Endpoint plumbing is only meaningful for the
// active connection — EndpointResolver only tracks
// probe state for the currently-connected relay.
// Non-active cards get null and hide the expand
// affordance.
// Active-card-only deep content. Null-guard below
// means non-active cards stay flat (title + subtitle
// + action row) and don't collect any VM flows.
activeConnectionViewModel = if (isActive) connectionViewModel else null,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme,
onReconnect = onReconnectActive,
onRename = { newLabel -> onRenameConnection(connection.id, newLabel) },
onRepair = { onRepairConnection(connection.id) },
onRevoke = { onRevokeConnection(connection.id) },
onRemove = { onRemoveConnection(connection.id) },
onOpenApiInfo = { showApiInfoSheet = true },
onOpenRelayInfo = { showRelayInfoSheet = true },
onOpenSessionInfo = { showSessionInfoSheet = true },
onInsecureAckRequested = { showInsecureAckDialog = true },
onNavigateToPairedDevices = onNavigateToPairedDevices,
)
}
// Footer spacer so the last card isn't hidden by the FAB.
@@ -180,26 +237,77 @@ fun ConnectionsSettingsScreen(
}
}
}
// ── Screen-scope dialogs + info sheets ───────────────────────────────
// Kept here (not at card scope) so a card being disposed by LazyColumn
// while scrolling can't silently dismiss an open sheet.
if (connectionViewModel != null) {
if (showSessionInfoSheet) {
SessionInfoSheet(
connectionViewModel = connectionViewModel,
onDismiss = { showSessionInfoSheet = false },
)
}
if (showApiInfoSheet) {
ApiServerInfoSheet(
connectionViewModel = connectionViewModel,
onDismiss = { showApiInfoSheet = false },
)
}
if (showRelayInfoSheet) {
RelayInfoSheet(
connectionViewModel = connectionViewModel,
onDismiss = { showRelayInfoSheet = false },
)
}
if (showInsecureAckDialog) {
InsecureConnectionAckDialog(
onConfirm = { reason ->
connectionViewModel.setInsecureAckComplete(reason)
connectionViewModel.setInsecureMode(true)
showInsecureAckDialog = false
},
onCancel = {
// User bailed out of the ack — leave the toggle OFF
// in the Advanced section (it never flipped visually
// because onCheckedChange only routed through this
// dialog for first-enable).
showInsecureAckDialog = false
},
)
}
}
}
/**
* Per-connection Card. Non-active cards are flat (title + subtitle +
* actions). The active card grows an inline deep body — status rows,
* optional endpoints expander, Advanced expander, security posture
* strip — via the helpers in [ActiveConnectionSections.kt].
*
* The `activeConnectionViewModel` non-null check on the active card is
* the single gate for the deep content. When null (test fixtures,
* previews), we degrade gracefully to the flat layout — no VM flows
* are collected and no dialogs fire.
*/
@Composable
private fun ConnectionCard(
connection: Connection,
isActive: Boolean,
// Live state for the active connection. Null for inactive ones — they
// render the persistent "Paired 2 days ago" style timestamp instead.
liveState: RelayUiState?,
// Only non-null for the active connection. When present, the card
// grows an inline-expand affordance that reveals the shared
// EndpointsCard with role/probe/prefer chips — the same component the
// Settings → Connection detail screen uses. For non-active connections
// this is null and the card stays flat (matches pre-ADR-24 layout).
activeConnectionViewModel: ConnectionViewModel?,
relayEnabled: Boolean,
isDarkTheme: Boolean,
onReconnect: () -> Unit,
onRename: (String) -> Unit,
onRepair: () -> Unit,
onRevoke: () -> Unit,
onRemove: () -> Unit,
onOpenApiInfo: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
onInsecureAckRequested: () -> Unit,
onNavigateToPairedDevices: () -> Unit,
) {
var showRenameDialog by remember { mutableStateOf(false) }
var showRevokeConfirm by remember { mutableStateOf(false) }
@@ -212,9 +320,10 @@ private fun ConnectionCard(
MaterialTheme.colorScheme.surfaceVariant
}
// Only the active card gets endpoint state. Flows are collected at the
// card level (not hoisted) so non-active cards don't pay the
// DataStore read cost — they get nothing and render flat.
// Endpoint flows only start collecting when activeConnectionViewModel
// is non-null (active card only). Guards below make that a no-op for
// non-active cards — the `observeDeviceEndpoints()` collector never
// opens, no DataStore subscriber is registered.
val endpoints: List<EndpointCandidate> = if (activeConnectionViewModel != null) {
val list by activeConnectionViewModel.observeDeviceEndpoints()
.collectAsState(initial = emptyList())
@@ -238,6 +347,7 @@ private fun ConnectionCard(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// ── Title row ────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = connection.label,
@@ -259,39 +369,23 @@ private fun ConnectionCard(
}
}
// ── Subtitle: hostname + status + endpoints roles ──────────
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
// For the active card, surface live WSS state (Connected,
// Reconnecting…, Stale, etc.) so the subtitle is a real-time
// reflection rather than a stale "Paired 5 minutes ago". For
// non-active cards the persistent pairedAt timestamp is all we
// have, so it stays.
val pairedStatus = when {
liveState != null -> liveState.statusText(connectedLabel = "Connected")
connection.pairedAt != null -> formatPairedRelative(connection.pairedAt)
else -> "Not paired"
}
// ADR 24 — on the active card we also know the active endpoint
// role + the full set of roles the QR carried; splice both into
// the subtitle so the list entry matches what the Active card at
// Settings → Connection shows. Previous version said "2 endpoints"
// which was accurate but opaque — users couldn't tell at a glance
// whether their QR had LAN, Tailscale, Public, or some mix.
// ADR 24 — active-only endpoint role summary.
val endpointBadge = if (isActive && endpoints.isNotEmpty()) {
val activeLabel = activeEndpoint?.displayLabel() ?: "resolving…"
val allRoles = endpoints.map { it.displayLabel() }.distinct()
val rolesSummary = if (allRoles.size == 1) {
allRoles.first()
} else {
allRoles.joinToString(" + ")
}
val rolesSummary = if (allRoles.size == 1) allRoles.first()
else allRoles.joinToString(" + ")
" • Active: $activeLabel • $rolesSummary"
} else {
""
}
// Tint the subtitle amber when the live state is Stale so the
// row visually signals "attention — tap Reconnect" even before
// the explicit button is read. Other states keep the muted
// color.
val statusColor = if (liveState == RelayUiState.Stale) {
MaterialTheme.colorScheme.tertiary
} else {
@@ -305,126 +399,152 @@ private fun ConnectionCard(
overflow = TextOverflow.Ellipsis,
)
// ADR 24 — nudge operators on legacy single-endpoint pairings
// to re-pair so their QR picks up LAN + Tailscale + Public
// automatically. Only on the active card (non-active don't
// have endpoint state loaded) and only when exactly 1 endpoint
// exists — zero means "not multi-endpoint-aware at all" (legacy
// pre-ADR-24 pairing, expected to be empty), and ≥2 is already
// what we want.
// ── Single-endpoint nudge (active only) ──────────────────────
if (isActive && endpoints.size == 1) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Only one endpoint in this pairing — re-pair with " +
"Mode = Auto to get LAN + Tailscale + Public in one QR.",
text = "Legacy single-endpoint pairing",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Re-pair with Mode = Auto to get LAN + Tailscale + " +
"Public in one QR.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRepair) {
TextButton(
onClick = onRepair,
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 0.dp,
),
) {
Text("Re-pair")
}
}
}
}
// ── Action row ───────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Stale-only Reconnect action. Promoted to first position so
// it reads as the primary recovery affordance when the row
// needs it, and simply absent otherwise so the Rename/Re-pair/
// Revoke/Remove sequence isn't disrupted.
if (liveState == RelayUiState.Stale) {
TextButton(onClick = onReconnect) {
Text("Reconnect")
}
}
TextButton(onClick = { showRenameDialog = true }) {
Text("Rename")
}
TextButton(onClick = onRepair) {
Text("Re-pair")
}
TextButton(onClick = { showRevokeConfirm = true }) {
Text("Revoke")
TextButton(onClick = onReconnect) { Text("Reconnect") }
}
TextButton(onClick = { showRenameDialog = true }) { Text("Rename") }
TextButton(onClick = onRepair) { Text("Re-pair") }
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
TextButton(onClick = { showRemoveConfirm = true }) {
Text(
text = "Remove",
color = MaterialTheme.colorScheme.error,
)
Text(text = "Remove", color = MaterialTheme.colorScheme.error)
}
}
// Inline endpoints expander — only on the active card and only
// when the paired device actually has endpoints stored (v3+
// pairings). Legacy pairings get no affordance so we don't
// pretend they have information we can't render.
if (activeConnectionViewModel != null && endpoints.isNotEmpty()) {
// ── Active-card-only deep content ────────────────────────────
// Gate everything on activeConnectionViewModel != null so
// preview fixtures / test harnesses degrade to the flat layout
// without VM collection.
if (isActive && activeConnectionViewModel != null) {
HorizontalDivider()
var preferredRole by remember {
mutableStateOf(activeConnectionViewModel.getPreferredEndpointRole())
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
text = if (endpointsExpanded) "Hide endpoints" else "Show endpoints",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { endpointsExpanded = !endpointsExpanded }) {
Icon(
imageVector = if (endpointsExpanded) {
Icons.Filled.ExpandLess
} else {
Icons.Filled.ExpandMore
// Status section (3 tappable rows → info sheets). Always
// visible on the active card — the "health dashboard"
// replacing the old Settings-top quick-look card.
ActiveCardStatusSection(
connectionViewModel = activeConnectionViewModel,
relayEnabled = relayEnabled,
onOpenApiInfo = onOpenApiInfo,
onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo,
)
// Endpoints expander (conditional on having endpoints).
// ADR 24 behavior preserved verbatim from pre-refactor.
if (endpoints.isNotEmpty()) {
HorizontalDivider()
var preferredRole by remember {
mutableStateOf(activeConnectionViewModel.getPreferredEndpointRole())
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
text = if (endpointsExpanded) "Hide endpoints"
else "Show endpoints (${endpoints.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { endpointsExpanded = !endpointsExpanded }) {
Icon(
imageVector = if (endpointsExpanded) Icons.Filled.ExpandLess
else Icons.Filled.ExpandMore,
contentDescription = if (endpointsExpanded) "Collapse endpoints"
else "Expand endpoints",
)
}
}
if (endpointsExpanded) {
EndpointsCard(
endpoints = endpoints,
activeEndpoint = activeEndpoint,
preferredRole = preferredRole,
onPreferEndpoint = { candidate ->
activeConnectionViewModel.setPreferredEndpointRole(candidate.role)
preferredRole = candidate.role
},
contentDescription = if (endpointsExpanded) {
"Collapse endpoints"
} else {
"Expand endpoints"
onClearOverride = {
activeConnectionViewModel.setPreferredEndpointRole(null)
preferredRole = null
},
onProbeNow = { activeConnectionViewModel.probeNow() },
onViewPin = { candidate ->
activeConnectionViewModel.lookupEndpointPin(candidate)
},
)
}
}
if (endpointsExpanded) {
EndpointsCard(
endpoints = endpoints,
activeEndpoint = activeEndpoint,
preferredRole = preferredRole,
onPreferEndpoint = { candidate ->
activeConnectionViewModel.setPreferredEndpointRole(candidate.role)
preferredRole = candidate.role
},
onClearOverride = {
activeConnectionViewModel.setPreferredEndpointRole(null)
preferredRole = null
},
onProbeNow = { activeConnectionViewModel.probeNow() },
onViewPin = { candidate ->
activeConnectionViewModel.lookupEndpointPin(candidate)
},
)
}
HorizontalDivider()
// Advanced expander: manual URL config + insecure toggle
// + manual pairing code fallback. Collapsed by default —
// the canonical path is the Re-pair button up top.
ActiveCardAdvancedSection(
connectionViewModel = activeConnectionViewModel,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme,
onInsecureAckRequested = onInsecureAckRequested,
)
HorizontalDivider()
// Security posture strip: transport badge + Tailscale chip
// + hardware keystore badge + Paired Devices row.
ActiveCardSecurityPosture(
connectionViewModel = activeConnectionViewModel,
onNavigateToPairedDevices = onNavigateToPairedDevices,
)
}
}
}
// ── Card-scope dialogs (rename / revoke / remove confirms) ──────────
// These survive card-level recomposition; they DON'T need screen-
// scope hoisting because they're tied to a single card and cannot
// logically open on two cards at once.
if (showRenameDialog) {
RenameConnectionDialog(
initialLabel = connection.label,
@@ -435,7 +555,6 @@ private fun ConnectionCard(
},
)
}
if (showRevokeConfirm) {
AlertDialog(
onDismissRequest = { showRevokeConfirm = false },
@@ -452,18 +571,13 @@ private fun ConnectionCard(
onRevoke()
showRevokeConfirm = false
},
) {
Text("Revoke")
}
) { Text("Revoke") }
},
dismissButton = {
TextButton(onClick = { showRevokeConfirm = false }) {
Text("Cancel")
}
TextButton(onClick = { showRevokeConfirm = false }) { Text("Cancel") }
},
)
}
if (showRemoveConfirm) {
AlertDialog(
onDismissRequest = { showRemoveConfirm = false },
@@ -481,16 +595,11 @@ private fun ConnectionCard(
showRemoveConfirm = false
},
) {
Text(
text = "Remove",
color = MaterialTheme.colorScheme.error,
)
Text(text = "Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) {
Text("Cancel")
}
TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") }
},
)
}
@@ -502,63 +611,55 @@ private fun RenameConnectionDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember { mutableStateOf(initialLabel) }
// Validate on every keystroke so the Save button disables + the
// supporting text appears without needing a failed submit first.
val validationError = com.hermesandroid.relay.data.ConnectionValidation.validateLabel(value)
var input by remember { mutableStateOf(initialLabel) }
val validation = com.hermesandroid.relay.data.ConnectionValidation.validateLabel(input)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename connection") },
text = {
OutlinedTextField(
value = value,
onValueChange = { value = it },
singleLine = true,
label = { Text("Label") },
isError = validationError != null && value.isNotEmpty(),
supportingText = {
// Only show the error message once the user has typed
// *something* — starting with the initial value we don't
// want the dialog to appear with a red "can't be blank"
// on first open.
if (validationError != null && value.isNotEmpty()) {
Text(validationError)
}
},
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
singleLine = true,
isError = validation != null,
supportingText = {
if (validation != null) Text(validation)
},
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = validationError == null,
onClick = { onConfirm(value.trim()) },
onClick = { onConfirm(input.trim()) },
enabled = validation == null && input.trim() != initialLabel,
) {
Text("Save")
Text("Rename")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* Coarse-grained relative time — "Paired 3d ago". We don't need exact
* precision here, and rolling our own avoids adding android.text.format or
* ThreeTenABP dependencies just for one subtitle.
* Hand-rolled "N minutes ago" / "N hours ago" / "N days ago" formatter
* for the non-active card subtitle. Could use `DateUtils.getRelativeTimeSpanString`
* but that returns awkward copy ("in 0 minutes") for small deltas.
*/
private fun formatPairedRelative(pairedAtMillis: Long): String {
val deltaMs = System.currentTimeMillis() - pairedAtMillis
if (deltaMs < 0) return "Paired"
if (deltaMs < 0) return "Just paired"
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
return when {
minutes < 1L -> "Paired just now"
minutes < 60L -> "Paired ${minutes}m ago"
hours < 24L -> "Paired ${hours}h ago"
days < 30L -> "Paired ${days}d ago"
else -> "Paired ${days / 30L}mo ago"
minutes < 1L -> "Just paired"
minutes < 60L -> "Paired $minutes minute${if (minutes == 1L) "" else "s"} ago"
hours < 24L -> "Paired $hours hour${if (hours == 1L) "" else "s"} ago"
days < 30L -> "Paired $days day${if (days == 1L) "" else "s"} ago"
else -> "Paired"
}
}
@@ -57,13 +57,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.ConnectionStatusRow
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.viewmodel.asBadgeState
import com.hermesandroid.relay.viewmodel.statusText
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -98,12 +94,15 @@ fun SettingsScreen(
// confused users, who expected dismissing the sheet to drop them back
// on Settings, not on a different tab. The local state + AgentInfoSheet
// block at the bottom of this composable drives the flow directly.)
// Multi-connection: entry point for the Connections manager. Kept at
// the top of the category list so switching server connections is one
// tap from the bottom nav, not buried behind "Connection → Paired
// devices".
// Connections manager — the unified home for everything connection-
// related. Kept at the top of the category list so switching server
// connections is one tap from the bottom nav. The former "Active
// Connection quick-look card" that lived here (and navigated into a
// second singular detail screen) was removed on 2026-04-21 — the
// plural Connections screen's active card now owns the full status
// + manual URL + insecure toggle + manual pairing code surface via
// expandable sections, so there's nothing left to link to twice.
onNavigateToConnections: () -> Unit,
onNavigateToConnectionSettings: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToMediaSettings: () -> Unit,
onNavigateToAppearanceSettings: () -> Unit,
@@ -127,33 +126,22 @@ fun SettingsScreen(
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
// Active Agent card inputs — personality + profile drive the title,
// ring-accent, and subtitle. Kept next to the other top-level
// collectAsState calls so the data-gather stays in one block.
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val relayFeatureEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
// Kick a WSS reconnect when Settings first composes so the Active
// Connection card doesn't flash red/Disconnected on cold entry. Without
// this, the relay row rendered with whatever state ConnectionManager
// held after the last resume — which on fresh launch is often
// Disconnected since RelayApp's ON_RESUME fires before the tab even
// exists on screen. Matches ConnectionSettingsScreen's behavior so the
// two paths converge.
// Kick a WSS reconnect when Settings first composes so the Connections
// subpage's active-card relay row doesn't flash Disconnected on cold
// entry. ConnectionsSettingsScreen runs the same `reconnectIfStale()`
// on its own entry, but firing here too means the first "Settings →
// Connections" navigation lands on an already-warm reconnect attempt
// rather than triggering it on arrival.
LaunchedEffect(Unit) {
connectionViewModel.reconnectIfStale()
}
@@ -224,98 +212,19 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
// ── Connection quick-look card ─────────────────────────────
// Live status summary for API + relay + session, tappable as a
// shortcut into the Connection sub-screen where the full
// pairing / manual configuration UX lives.
Card(
modifier = Modifier
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
)
.clickable(onClick = onNavigateToConnectionSettings),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Active Connection",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
// Connection label lands right under the title so
// users with multiple saved connections can see at
// a glance which one the status rows describe. Fall
// back to "No connection" only when the store has
// no active entry — normal startup seeds one.
Text(
text = activeConnection?.label ?: "No connection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ConnectionStatusRow(
label = "API",
isConnected = apiReachable,
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
statusText = when {
apiUrl.isBlank() -> "Not configured"
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
apiReachable -> apiUrl
else -> "Unreachable"
}
)
if (relayFeatureEnabled) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
// All state-resolution rules live in ConnectionViewModel's
// relayUiState flow — this row just maps that resolved
// state onto the row's badge + text. Settings shows the
// full relay URL in the Connected case; other screens
// show "Connected" via a different label.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
// RelayRowState.statusText appends " · <Role>" when
// the ADR 24 resolver has picked an endpoint, so
// the chip reads "Connected · Tailscale" etc.
statusText = relayRowState.statusText(connectedLabel = relayUrl),
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing…"
is AuthState.Failed -> "Failed"
is AuthState.Unpaired -> "Not paired"
}
)
}
}
}
// (The "Active Connection quick-look card" that used to live
// here — showing API / Relay / Session status rows with a
// clickable shortcut into a separate singular-connection
// detail screen — was removed on 2026-04-21. It was the
// second "connection status" surface on a screen that already
// had the Active Agent card above it, and it pointed at a
// near-identically-named screen (`ConnectionSettings` singular
// vs `ConnectionsSettings` plural) which users couldn't tell
// apart. Everything the card did — status rows, pair, manual
// URL, insecure toggle, manual pairing code — now lives inline
// on the active card of the Connections subpage, reached via
// the "Connections" category row below. See ADR on the
// connection-settings unification.)
// ── Category list ──────────────────────────────────────────
// Multi-connection: Connections sits at the very top of the
+5 -5
View File
@@ -135,7 +135,7 @@ hermes-pair --ttl 1d
Supported duration formats: `1d`, `7d`, `30d`, `90d`, `1y`, `never` (or any `<number><unit>` combo where unit is `s`/`m`/`h`/`d`/`w`/`y`). Grants can be pre-set for the `terminal` and `bridge` channels and are automatically clamped to the overall session TTL — a grant cannot outlive its session.
::: tip Camera unavailable? Use manual pairing
If you can't scan a QR — for example you're SSH'd into the host from the same phone you want to pair, the host has no display attached, or there's no second camera-equipped device handy — Hermes-Relay ships a manual fallback flow. Open the app's **Settings → Connection → Manual pairing code (fallback)** card to read its locally-generated 6-char code, then on the host run:
If you can't scan a QR — for example you're SSH'd into the host from the same phone you want to pair, the host has no display attached, or there's no second camera-equipped device handy — Hermes-Relay ships a manual fallback flow. Open the app's **Settings → Connections → [active card] → Advanced → Manual pairing code (fallback)** section to read its locally-generated 6-char code, then on the host run:
```bash
hermes-pair --register-code ABCD12 # default 30d session
@@ -165,10 +165,10 @@ The app also runs a **Trust On First Use** (TOFU) cert pinning check on `wss://`
#### Paired Devices management
**Settings → Connection → Paired Devices** lists every device currently paired with the relay — device name, transport badge, session expiry, per-channel grant chips, and a **Revoke** button per row. Revoking the current device wipes local state and redirects to the pair flow. Any paired device can revoke any other; for single-operator setups this is intentional (so you can manage everything from one phone), multi-user deployments will need a role model later.
**Settings → Connections → [active card] → Paired Devices** lists every device currently paired with the relay — device name, transport badge, session expiry, per-channel grant chips, and a **Revoke** button per row. Revoking the current device wipes local state and redirects to the pair flow. Any paired device can revoke any other; for single-operator setups this is intentional (so you can manage everything from one phone), multi-user deployments will need a role model later.
::: tip Multiple Hermes servers
The app supports pairing with more than one Hermes server (home + work, dev + prod, etc.) and switching with a single tap. Once you've paired the first server, open **Settings → Connections** to add a second — it launches the same QR flow. A Connection chip appears on the left of the Chat top bar as soon as you have two or more, letting you switch without re-pairing. See [Connections](/features/connections) for the full model.
The app supports pairing with more than one Hermes server (home + work, dev + prod, etc.) and switching with a single tap. Once you've paired the first server, open **Settings → Connections** to add a second — it launches the same QR flow. When you have two or more, a **Connection** radio list appears inside the agent sheet (tap the agent name in the Chat top bar), letting you switch without re-pairing. See [Connections](/features/connections) for the full model.
:::
::: warning Security
@@ -280,7 +280,7 @@ If you don't want to use QR pairing, you can enter connection details by hand
5. Optionally enter a **Relay URL** for Terminal/Bridge features
6. Tap **Get Started**
**After onboarding:** open **Settings → Connection**. The top card (**Pair with your server**) shows a **Scan Pairing QR** button and a status summary for the API server, relay, and session. To enter values by hand, expand the **Manual configuration** card below it — API Server URL, API Key, Relay URL, and Insecure Mode live there, along with **Save & Test**.
**After onboarding:** open **Settings → Connections**. Each paired server is a card in the list; the currently-active card expands inline to show status rows, endpoint details, and an **Advanced** section with manual URL config, insecure-mode toggle, and the manual pairing-code fallback flow. The per-card **Re-pair** button is the one-tap entry point for scanning a new QR. API Server URL, API Key, Relay URL, and Insecure Mode all live under the active card's **Advanced** expander, with **Save & Test** for each.
The `hermes-pair` command always prints these same values as plain text alongside the QR code, so you can copy them directly.
@@ -313,7 +313,7 @@ Hermes-Relay supports **multi-endpoint pairing**: one QR carries every network p
**Forcing a specific mode at pair time** — `--prefer <role>` promotes a named role to priority 0 (e.g. `--prefer tailscale` for a QR biased toward the tailnet even when LAN is reachable). Open vocabulary — any role string you pass through `--mode` or a custom operator setup works here.
**Override per-session on the phone** — Settings → Connection → Endpoints card → row menu → **Prefer this endpoint**.
**Override per-session on the phone** — Settings → Connections → [active card] → **Show endpoints** expander → row menu → **Prefer this endpoint**.
For the full matrix (Tailscale, Caddy + Let's Encrypt, Cloudflare Tunnel, self-hosted WireGuard, plaintext over trusted VPN) with working config blocks, see [remote-access.md](https://github.com/Codename-11/hermes-relay/blob/main/docs/remote-access.md) and the [Connections page](/features/connections#multi-endpoint-pairing-one-qr-for-every-network).
+15 -5
View File
@@ -4,11 +4,21 @@ Hermes-Relay stores its settings using Android's DataStore, Android Keystore (fo
## Connection Settings
These are configured during onboarding or from the **Settings → Connection** screen. The Connection screen groups everything under a single section with three cards:
These are configured during onboarding or from the **Settings → Connections** screen. That screen is the single authoritative home for everything connection-related (as of the 2026-04-21 unification — the legacy singular *Settings → Connection* subpage was folded in here). Each paired server appears as its own card in the list; the currently-active card expands inline to surface all deep-configuration UI.
- **Pair with your server** — always visible. One-tap entry point: a **Scan Pairing QR** button plus a unified status summary (API Server reachable, Relay connected, Session paired). One scan of the QR printed by `/hermes-relay-pair` (or the `hermes-pair` shell shim) configures everything. When paired, a **Transport Security** badge (🔒 secure / 🔓 insecure with reason / 🔓 unknown) and a **Tailscale detected** chip (if applicable) surface above the status rows. A **Paired Devices** row navigates to the full device-management screen.
- **Manual configuration** — collapsible. Starts collapsed when you're already paired and reachable, expanded otherwise. Holds the manual-entry fields below and a **Save & Test** action. This is the power-user / troubleshooting path. Toggling **Insecure mode** (plain `ws://` instead of `wss://`) for the first time opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). The reason is displayed on the Transport Security badge but is not enforced — the operator's intent is the trust model.
- **Manual pairing code (fallback)** — collapsible and only visible when the relay feature flag is on. Shows a locally-generated 6-char code with copy / regenerate icons. This is a **fallback for when you can't use the QR scan flow** — for example, the device that needs to pair is also your only camera, you're SSH'd into the host from your phone, or the host has no display attached. **Workflow:** (1) open this card and read the displayed code; (2) on the host, run `hermes-pair --register-code <code>` (the command pre-registers the code with the local relay over loopback and confirms which code it accepted); (3) come back to this card in the app and tap **Connect**. The relay accepts the code, mints a session, and you're paired. The canonical flow is still the QR from `/hermes-relay-pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
**On any card (active or not) — the per-connection action row:**
- **Reconnect** (only on Stale state)
- **Rename**, **Re-pair**, **Revoke**, **Remove**
**On the active card — a deep body below the action row:**
- **Status rows** (API / Relay / Session) — always visible, tappable to open detail sheets with token, endpoint, health, and session info.
- **Endpoints** (collapsible, when the pairing carries them) — role chips (LAN / Tailscale / Public / Custom VPN), probe-now button, per-candidate *Prefer this endpoint* override, per-candidate TOFU pin inspection.
- **Advanced** (collapsible) — the power-user / troubleshooting path. Holds:
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**.
- **Allow insecure connections** toggle — first enable opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason is displayed on the Transport Security badge below but is not enforced — operator intent is the trust model.
- **Disconnect** button — drops the active WSS without clearing the session token.
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes-pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
- **Security posture strip** (always visible) — Transport Security badge (🔒 secure / 🔓 insecure with reason / 🔓 unknown), Tailscale-detected chip, Hardware-keystore badge, and a **Paired Devices** row that navigates to the full device list.
| Setting | Storage | Description |
|---------|---------|-------------|
@@ -37,7 +47,7 @@ Per-channel grants (`terminal`, `bridge`) can be pre-set by the operator via `he
### Paired Devices
**Settings → Connection → Paired Devices** opens a full-screen list of every device currently paired with the relay. Each card shows:
**Settings → Connections → [active card] → Paired Devices** opens a full-screen list of every device currently paired with the relay. Each card shows:
- Device name + device ID
- **Current device** badge if this is the device you're looking at the list on