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:
co-authored by
Claude Sonnet 4.6
parent
884470979c
commit
5a38b69067
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+855
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+292
-191
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user