* feat(#349): smart auth login flow — probe dashboard, show correct fields
Landing screen with two buttons (Auth Login / Pairing Login) replaces
ConnectScreen as the start screen. Auth login probes the dashboard to
detect what auth it needs (token-only, basic-auth, or both) and shows
only the relevant input fields.
Changes:
- NavigationKeys.kt: add LandingScreen, AuthLoginScreen NavKeys
- LandingScreen.kt: new screen with hero icon + two login buttons
- AuthLoginScreen.kt: IP/Port input, probe logic, dynamic field rendering
- AuthLoginViewModel.kt: probe API, dashboard auth detection, connect flow
- Navigation.kt: wire new screens, start on LandingScreen, hide bottom bar
- strings.xml: add string resources for both screens
* fix(#351): align AuthLoginScreen field names with ViewModel API, fix TAG refs
- state.isProbing → state.probing
- DashboardAuthMode.BOTH → DashboardAuthMode.ALL
- viewModel.probe() → viewModel.probeDashboard()
- state.isConnecting → state.isLoading
- Removed unused TAG constant, replaced with inline tag string
- Made connectTokenOnly suspend to fix safeApiCall compilation
This fixes the CI compilation errors.
* fix(#351): revert probeDashboard() call to probe() to match VM
* fix(#351): rename isProbing to probing in ViewModel
* fix(#351): follow redirects in basic auth login — / redirects to /login
* fix(#351): show all fields when basic auth detected — still needs session token for WS
* refactor(#349): remove old ConnectScreen, clean up NavKey + navigation
* fix(#351): validate token against /api/sessions (auth-protected) instead of /api/status (public)
/api/status is a public endpoint that returns 200 even with wrong tokens,
causing invalid tokens to be saved and the WebSocket to spin forever on
the chat screen. /api/sessions requires auth and returns 401 on bad tokens.
* fix(#351): rewrite basic auth login — POST to /auth/password-login, then fetch SPA with cookie to extract token
* fix(#351): use ticket-based WS auth when basic auth is active
The updated dashboard on 0.0.0.0 always has auth_required=True, which
rejects legacy ?token= in favor of ?ticket= (single-use, 30s TTL).
Added AuthManager.setWsAuthParam() to support both ?token= (loopback)
and ?ticket= (gated mode) in wsUrl(). connectBasicAuth now mints a
ticket via POST /api/auth/ws-ticket after login instead of extracting
the token from the SPA.
* fix(#351): use requirePrefs() instead of direct prefs reference in wsUrl()
* fix(#351): handle relaxed-mock empty string in wsUrl() authParam fallback
* feat(#349): auto-extract session token from SPA HTML during probe
The probe now extracts the __HERMES_SESSION_TOKEN__ value from the
dashboard SPA and auto-populates the token field in TOKEN_ONLY mode,
eliminating the need for manual token pasting.
- Introduced ProbeResult data class to carry authMode + extractedToken
- Regex extraction of __HERMES_SESSION_TOKEN__ from dashboard HTML
- Token field auto-filled on probe completion for TOKEN_ONLY mode
* fix(#349): return BASIC_AUTH from probe when basic auth detected
Bug: probe returned ALL instead of BASIC_AUTH for basic-auth-only
dashboards, causing token+credential fields to show and the connect
flow to fall through to connectTokenOnly() on basic auth failure,
producing a confusing 'Unauthorized (HTTP 401)' NetworkResult error.
Fix: probe now returns DashboardAuthMode.BASIC_AUTH when a redirect
to /login is detected. BASIC_AUTH in connect() calls
connectBasicAuth() directly with no fallback. ALL mode also no longer
falls through to connectTokenOnly().
* fix(#349): use session cookie for REST API in gated mode
The dashboard's gated mode authenticates REST API requests via
session cookies (hermes_session_at), not Bearer tokens. The app
was storing the WS ticket as the token and sending it as
Authorization: Bearer <ticket> for all REST calls, which the
server rejected with 401.
- Extract hermes_session_at cookie from Set-Cookie header after
POST /auth/password-login and store it in AuthManager
- ApiClient authInterceptor now sends Cookie header when session
cookie is present, falls back to Bearer token for loopback mode
- ConnectResult carries both WS credential (ticket) and session
cookie separately
- WS ticket used only for WebSocket (?ticket=), session cookie
used for REST API
* feat(#349): add logout button to Settings > About
Red error-style button that clears all auth credentials (token,
session cookie, WS auth param) and navigates back to LandingScreen.
Wired through SettingsScreen onLogout callback to Navigation.kt.
* fix(#349): improve logout navigation flow
* fix(#349): keep backstack stable across logout
Use remember without keys for NavBackStack so the same backstack
object persists across startScreen changes. Use LaunchedEffect keyed
on startScreen to reset stack content atomically. This avoids
NavDisplay losing snapshot observation when a new NavBackStack
object is created.
* fix(#349): mint fresh WS ticket on every connect
The WS ticket is a single-use 30-second credential. On app restart
(or reconnect), the stored ticket is expired. Now refreshWsTicketIfNeeded()
mints a new ticket using the session cookie before every WS connection
attempt. Loopback mode (no session cookie) is unaffected.
* fix(#349): refresh ticket in openSocket for reconnect paths
scheduleReconnect() bypasses connect() and calls openSocket()
directly. Moving the ticket refresh into openSocket() ensures
every WS connection attempt — first launch and reconnect —
uses a fresh ticket.
* fix(#349): stub getSessionCookie() in ApiClientTest
mockkObject(AuthManager) is strict, so unstubbed getSessionCookie()
calls throw MockKException which manifests as IOException.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
- Adheres to ktlint conventions
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
- Fixed UI bug by removing padding (`edgePadding = 0.dp`) in the TabRow to avoid excessive white space
- Adheres to ktlint conventions
* fix(#345): remove double padding on settings tabs
HermesScaffold already applies Scaffold's inner padding (top bar height +
status bar insets) via its own Box wrapper. The tabbed settings layout
was applying the same paddingValues a second time on the outer Column,
causing the tab row to sit ~64dp too low. Fix removes the redundant
.padding(paddingValues) call.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#220): implement edit soul and set model dialogs
* fix(#211): make UI more compact and information dense
* fix(#211): make navigation drawer sidebar more compact
* feat(#252): move settings item to end of navigation drawer
* feat(#250): add bottom navigation bar display mode settings
* feat(#225): add search bar filtering to remaining screens
* fix: make bottom navigation bar display modes more compact
* fix: resolve compilation error in KanbanScreen by referencing assignedTo
* feat: add search to achievements screen and top padding for icon-only navigation bar
* fix: adjust icon-only bottom bar height and remove padding
* fix(#249): remove unused PaddingValues imports in 6 screens
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: move session history to a dedicated history screen
Closes#159.
* fix(ci): fix ktlint style issues and unresolved NavigationController reference
## What
Adds a new Navigation Bar section to Settings where users can customize which items appear in the bottom navigation bar.
## Features
- Pick from any of the 19 drawer screens (max 5 items)
- Remove items with a close/delete button
- Items persist via SharedPreferences (CSV format)
- Invalid names are silently dropped
- Existing screens and auth-aware filtering respected
Closes#23
- HermesScaffold: title now @Composable () -> Unit (M3-idiomatic)
- HermesScaffold: added modifier and snackbarHost parameters
- Updated all 18 callers from title="X" to title={Text("X")}
- ChatScreen: converted from raw Scaffold to HermesScaffold
- Removed unused Menu icon import (ktlint fix)
* refactor(#5): convert SettingsScreen to use HermesScaffold
Replace raw Material3 Scaffold + manual TopAppBar with the shared
HermesScaffold wrapper. Removes ~30 lines of duplicated boilerplate
and gives SettingsScreen scroll-aware top bar behavior.
Closes#5
* fix(#5): restore ExperimentalMaterial3Api import for SegmentedButton
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
B7 (Jun 18 2026): user reported Settings page unresponsive when
reached via the ChatScreen topbar gear icon, but worked fine via
the drawer sidebar. Two fixes:
1. Route topbar gear through NavigationController.navigateTo()
instead of raw backStack.add(). The NavigationController path
(used by the drawer) deduplicates against the current top
entry and matches the drawer's navigation flow exactly. The
bypass path could stack duplicate SettingsScreen entries on
rapid taps, which likely caused the unresponsive symptom.
2. Add 'Clear Token' OutlinedButton below the Auth Token field in
Settings. One-tap wipes a corrupted / rejected token and saves
immediately, without having to select-and-delete the masked
password field character by character. Recovers from the
'I gave you a bad token' scenario in seconds.
Wave 1 — CRITICAL (token + PII leak):
B1 (kanban t_afc1d26f): Auth token leaked to logcat via
HttpLoggingInterceptor.Level.BODY combined with the authInterceptor
that adds `Authorization: Bearer *** on every REST call. Enabled
buildConfig=true in app/build.gradle.kts and gate Level.BODY on
BuildConfig.DEBUG (Level.NONE in release). ApiClient.kt:44-56.
B2 (kanban t_8884db16): Chat prompts + AI replies leaked to logcat
via unguarded Log.d in HermesWsClient. Gated the 3 PII-critical
sites (line 104 outgoing JSON which contains user `text`, line ~123
connecting URL whose query string carries the auth token, line ~181
incoming WS payload with AI reply tokens and tool output) with
BuildConfig.DEBUG. Log.i/.w/.e left unguarded as intentional ops signals.
Wave 2 — HIGH (races + thread-pattern):
B3 (kanban t_33da8a97): switchSession fired SESSION_RESUME (WS) and
loadSessionMessages (REST) in parallel. The REST success path did an
unconditional `_uiState.update { it.copy(messages = chatMessages) }`
that stomped any in-flight streaming tokens appended via
WsEvent.MessageToken. ChatViewModel.loadSessionMessages now preserves
messages with isStreaming=true by re-appending them to the freshly
loaded history. Bonus side-fix discovered in same function: isLoading
was never set to false on success or error branches — now explicitly
cleared on all exit paths.
B4 (kanban t_2b834d90): HermesWsClient.scheduleReconnect used a raw
Thread{} with Thread.sleep + a non-atomic check-then-openSocket()
(TOCTOU). Two reconnect threads spawned by a rapid onFailure/onClosed
cycle could both pass the gate and both call openSocket(), the second
overwriting `webSocket` without closing the first — orphaned socket
listener leak. Flaky networks multiplied daemon thread count. Replaced
with a single cancellable coroutine Job keyed to wsScope
(Dispatchers.IO + SupervisorJob). disconnect() now cancels any pending
reconnect so a delayed openSocket() can't fire after explicit close.
Wave 3 — MEDIUM (UX gaps + persistence):
B5 (kanban t_2322818d): LogsScreen rendered a blank black Box when
state.logs was empty && !isLoading && errorMessage == null. Added an
explicit empty-state branch rendering "No logs available" in
onSurfaceVariant, centered. Mirrors sibling pattern in
PairingScreen.kt:200-219 and WebhooksScreen.kt:171-190.
B6 (kanban t_86e9be9b): Theme preference was in-memory only — lost on
every cold start. Verification confirmed three gaps: (1)
SettingsViewModel.loadSettings() didn't restore themePreference from
storage; (2) SettingsViewModel.save() didn't persist it; (3)
MainActivity called `HermesControlTheme {}` with no themePreference
arg so even if storage had worked, the app wouldn't apply it at
startup. Added getThemePreference/setThemePreference in AuthManager
(EncryptedSharedPreferences round-trip, validate stored enum name with
runCatching to handle forward-incompatible strings), wired
SettingsViewModel init + save, and passed the stored preference to
HermesControlTheme(themePreference = AuthManager.getThemePreference())
at startup.
Verification:
- ktlintCheck: BUILD SUCCESSFUL in 7s (ktlint app/src/**/*.kt — clean)
- ./gradlew test: NOT RUN on this host (no Android SDK at
$ANDROID_HOME); deferred to the CI workflow at
.github/workflows/android.yml. Same limitation applies to
assembleRelease. Symbol-resolution reasoning: app/build.gradle.kts:10
declares namespace=com.m57.hermescontrol so AGP auto-generates the
BuildConfig class at package com.m57.hermescontrol — matches the new
imports in ApiClient.kt and HermesWsClient.kt.
Kanban tracking (native Hermes kanban, board slug "hermes-mobile"):
B1 t_afc1d26f done - Auth token leak via Level.BODY
B2 t_8884db16 done - Chat PII via unguarded Log.d
B3 t_33da8a97 done - switchSession race stomps streaming tokens
B4 t_2b834d90 done - WS reconnect TOCTOU + unbounded Thread{}
B5 t_2322818d done - LogsScreen missing empty-state
B6 t_86e9be9b done - Theme preference not persisted
B7 t_74f3b383 ready - (deferred) NavigationController.goBack underflow
B8 t_39a149f0 ready - (deferred) KanbanBoardsResponse.current nullable
B9 t_4b1465cf ready - (deferred) E2eIntegrationTest base64 no-op via Mockk
B10 t_81ef4203 ready - (deferred) ChannelsScreen form state on scroll
B11 t_3325498b ready - (deferred) TEST_READY/TEST_INFRA stale (28 vs 123)
B12 t_674007f5 ready - (deferred) Hardcoded http:// (LAN assumption)
B13 t_179009d5 ready - (deferred) SystemViewModel silent doctor catch
Total: 8 files changed, +144 insertions, -26 deletions.
Co-Authored-By: Ronia <ronia@hermes>