[feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)

* [feature] issue #634: HTTPS + reverse-proxy server endpoint support

- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API

Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>

* fix(authlogin): basic-auth derives token automatically, no manual entry

connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.

Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).

Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).

Fixes the basic-auth token prompt reported against issue #634.

* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)

* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)

* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login

- Replace 302-sniff probe with authoritative GET /api/status read
  (auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
  (basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.

Closes nothing; OAuth tracked in #639.

* fix(chat): don't block WS connect on empty token in gated (ticket) mode

connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.

Only require a non-blank token in non-gated mode.

Fixes #640.

* test: remove onPairingString test cases for deleted pairing-login path

The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.

Fixes the Unit & Integration Tests CI failure on PR #638.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-07-20 17:26:10 +03:00
1 parent d839c5e9ad
commit 26b104b2bb
40 files changed
+1299 -688

No files matched your search

@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -7,6 +9,21 @@ import java.util.UUID
data class ConnectionProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int,
)
/** Legacy fields retained only so pre-base-URL profiles can migrate. */
val host: String = "127.0.0.1",
val port: Int = 9119,
val baseUrl: String? = null,
) {
val resolvedBaseUrl: String
get() =
baseUrl
?.let {
ServerEndpoint
.parse(
it,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
}
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStoreState {
val updated = connectionProfiles.filter { it.id != profile.id } + profile
return copy(connectionProfiles = updated)
@@ -31,14 +34,36 @@ fun ServerStoreState.selfHealed(): ServerStoreState {
)
}
val ServerStoreState.resolvedHost: String
val ServerStoreState.resolvedBaseUrl: String
get() {
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId }
return selected?.host ?: host
if (selected != null) return selected.resolvedBaseUrl
return baseUrl
?.let {
ServerEndpoint
.parse(
it,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
}
val ServerStoreState.resolvedHost: String
get() =
ServerEndpoint
.parse(
resolvedBaseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.host
val ServerStoreState.resolvedPort: Int
get() {
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId }
return selected?.port ?: port
}
get() =
ServerEndpoint
.parse(
resolvedBaseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.port
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.config
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import com.m57.hermescontrol.data.remote.ServerEndpoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -29,7 +30,7 @@ class ServerStore(
try {
dataStore.data.first().selfHealed()
} catch (e: Exception) {
ServerStoreState()
ServerStoreSerializer.defaultValue
}
}
_stateFlow = MutableStateFlow(initial)
@@ -60,7 +61,8 @@ object ServerStoreSerializer : Serializer<ServerStoreState> {
prettyPrint = false
}
override val defaultValue: ServerStoreState = ServerStoreState()
override val defaultValue: ServerStoreState =
ServerStoreState(baseUrl = ServerEndpoint.DEFAULT_BASE_URL)
override suspend fun readFrom(input: InputStream): ServerStoreState =
try {
@@ -7,6 +7,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -18,11 +19,30 @@ class ServerStoreMigration(
companion object {
private const val PREFS_FILE = "hermes_secure_prefs"
private const val KEY_MIGRATED = "migrated_to_datastore"
private val LEGACY_KEYS =
setOf(
"host",
"port",
"auto_reconnect",
"theme_preference",
"use_dynamic_colors",
"theme_preset",
"bottom_nav_display_mode",
"bottom_nav_items",
"connection_profiles",
"selected_profile_id",
"pinned_models",
"ws_auth_param",
"typing_effect_enabled",
"typing_effect_delay_ms",
"auth_token",
)
}
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean {
val prefs = getPrefs() ?: return false
return !prefs.getBoolean(KEY_MIGRATED, false)
if (prefs.getBoolean(KEY_MIGRATED, false)) return false
return LEGACY_KEYS.any(prefs::contains)
}
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState {
@@ -92,13 +112,24 @@ class ServerStoreMigration(
return currentData.copy(
host = host,
port = port,
baseUrl = ServerEndpoint.fromLegacy(host, port).baseUrl.toString(),
autoReconnect = autoReconnect,
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
bottomNavDisplayMode = bottomNavDisplayMode,
bottomNavItems = bottomNavItems,
connectionProfiles = connectionProfiles,
connectionProfiles =
connectionProfiles.map { profile ->
profile.copy(
baseUrl =
profile.baseUrl
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
)
},
selectedProfileId = selectedProfileId,
pinnedModels = pinnedModels,
wsAuthParam = wsAuthParam,
@@ -8,8 +8,10 @@ import kotlinx.serialization.Serializable
@Serializable
data class ServerStoreState(
/** Legacy host and port retained for one-time migration. */
val host: String = "127.0.0.1",
val port: Int = 9119,
val baseUrl: String? = null,
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true,
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.data.config
import androidx.datastore.core.DataMigration
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
/** Migrates the former host-and-port model to canonical complete base URLs. */
class ServerUrlMigration : DataMigration<ServerStoreState> {
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean =
currentData.baseUrl.isNullOrBlank() ||
currentData.connectionProfiles.any { it.baseUrl.isNullOrBlank() }
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState = migrateState(currentData)
override suspend fun cleanUp() = Unit
companion object {
internal fun migrateState(state: ServerStoreState): ServerStoreState =
state.copy(
baseUrl =
normalizeExisting(state.baseUrl)
?: ServerEndpoint.fromLegacy(
state.host,
state.port,
).baseUrl.toString(),
connectionProfiles =
state.connectionProfiles.map { profile ->
profile.copy(
baseUrl =
normalizeExisting(profile.baseUrl)
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
)
},
)
private fun normalizeExisting(raw: String?): String? {
if (raw.isNullOrBlank()) return null
return ServerEndpoint
.parse(
raw,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
}
}
@@ -8,10 +8,14 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.ServerStore
import com.m57.hermescontrol.data.config.ServerStoreMigration
import com.m57.hermescontrol.data.config.ServerStoreSerializer
import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -87,7 +91,11 @@ object AuthManager {
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations = listOf(ServerStoreMigration(context)),
migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) {
context.filesDir.resolve("server_store.json")
}
@@ -174,9 +182,7 @@ object AuthManager {
fun getSessionCookie(): String? = CookieManager.getSessionCookie()
fun setSessionCookie(cookie: String?) {
// The session cookie is host-scoped to the current dashboard host so
// the CookieJar only attaches it to matching requests.
CookieManager.setSessionCookie(cookie, AuthManager.getHost())
CookieManager.setSessionCookie(cookie, endpoint())
}
/**
@@ -237,8 +243,7 @@ object AuthManager {
ConnectionProfile(
id = DEFAULT_PROFILE_ID,
name = DEFAULT_PROFILE_NAME,
host = s.host,
port = s.port,
baseUrl = s.resolvedBaseUrl,
),
),
selectedProfileId = DEFAULT_PROFILE_ID,
@@ -359,7 +364,7 @@ object AuthManager {
appScope?.let {
try {
it.cancel()
} catch (e: Exception) {
} catch (_: Exception) {
}
}
appScope = null
@@ -400,11 +405,24 @@ object AuthManager {
}
}
// ── Host ─────────────────────────────────────────────────────────────
// ── Server endpoint ──────────────────────────────────────────────────
fun getHost(): String = serverStore.getLatestState().resolvedHost
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun setHost(host: String) {
fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
fun endpointForBuild(): ServerEndpoint = ServerEndpoint.parseForBuild(getBaseUrl())
fun setBaseUrl(baseUrl: String) {
val normalized =
ServerEndpoint.parse(
baseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl.toString()
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -412,30 +430,36 @@ object AuthManager {
}
serverStore.update { state ->
val profiles =
state.connectionProfiles.map {
if (it.id == selectedId) it.copy(host = host) else it
state.connectionProfiles.map { profile ->
if (profile.id == selectedId) {
profile.copy(
host = "",
port = 0,
baseUrl = normalized,
)
} else {
profile
}
}
state.copy(connectionProfiles = profiles)
}
}
// ── Port ─────────────────────────────────────────────────────────────
/** Compatibility accessors for legacy pairing payloads. */
fun getHost(): String = serverStore.getLatestState().resolvedHost
fun getPort(): Int = serverStore.getLatestState().resolvedPort
fun setHost(host: String) {
val endpoint = endpoint()
val normalizedHost = host.trim().removePrefix("[").removeSuffix("]")
setBaseUrl(endpoint.baseUrl.newBuilder().host(normalizedHost).build().toString())
}
fun setPort(port: Int) {
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
DEFAULT_PROFILE_ID
}
serverStore.update { state ->
val profiles =
state.connectionProfiles.map {
if (it.id == selectedId) it.copy(port = port) else it
}
state.copy(connectionProfiles = profiles)
}
require(port in 1..65535) { "Port is out of range" }
val endpoint = endpoint()
setBaseUrl(endpoint.baseUrl.newBuilder().port(port).build().toString())
}
// ── Auto-reconnect ───────────────────────────────────────────────────
@@ -466,18 +490,17 @@ object AuthManager {
serverStore.update { it.copy(themePreset = preset) }
}
/** Convenience: build the base URL from current host + port.
* NOTE: Uses plain http:// — intended for trusted local network only.
* Exposing the host to a hostile LAN risks token interception. */
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
/** Canonical build-allowed Retrofit base URL. */
fun baseUrl(): String = endpointForBuild().baseUrl.toString()
/** Convenience: build the WebSocket URL with token query param.
* NOTE: Token in query string — trusted local network only. */
/** Canonical WebSocket URL with an encoded token or short-lived ticket. */
fun wsUrl(): String {
val raw = serverStore.getLatestState().wsAuthParam
val authParam = if (raw.isNullOrBlank()) "token" else raw
val credential = getToken().orEmpty()
return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential"
val authParam = if (raw.isBlank()) "token" else raw
return endpointForBuild().webSocketUrl(
authParameter = authParam,
credential = getToken().orEmpty(),
)
}
// ── Bottom nav bar items ──────────────────────────────────────────────
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.logging.HttpLoggingInterceptor
@@ -41,10 +40,10 @@ object ApiClient {
/** Creates a standalone, temporary [HermesApiService] without modifying the global instance. */
fun createTempService(
host: String,
port: Int,
baseUrl: String,
token: String,
): HermesApiService {
val endpoint = ServerEndpoint.parseForBuild(baseUrl)
val tempAuthInterceptor =
Interceptor { chain ->
val request =
@@ -70,7 +69,7 @@ object ApiClient {
val tempRetrofit =
Retrofit
.Builder()
.baseUrl("http://$host:$port/")
.baseUrl(endpoint.baseUrl)
.client(tempOkHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
@@ -85,7 +84,7 @@ object ApiClient {
HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
HttpLoggingInterceptor.Level.BASIC
} else {
HttpLoggingInterceptor.Level.NONE
}
@@ -110,8 +109,6 @@ object ApiClient {
}
}
val certificatePinner = CertificatePinner.Builder().build()
val okHttp =
OkHttpProvider
.base
@@ -120,13 +117,12 @@ object ApiClient {
.addInterceptor(ProfileScopeInterceptor)
.addInterceptor(logging)
.authenticator(TokenRefreshAuthenticator)
.certificatePinner(certificatePinner)
.build()
val rf =
Retrofit
.Builder()
.baseUrl(AuthManager.baseUrl())
.baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(okHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@Serializable
private data class PasswordLoginPayload(
val provider: String,
val username: String,
val password: String,
val next: String,
)
@Serializable
private data class WebSocketTicketPayload(
val ticket: String,
)
/** JSON serialization for authentication requests and responses. */
object AuthPayloads {
fun passwordLogin(
username: String,
password: String,
): String =
OkHttpProvider.json.encodeToString(
PasswordLoginPayload(
provider = "basic",
username = username,
password = password,
next = "",
),
)
fun webSocketTicket(body: String): String? =
runCatching {
OkHttpProvider.json.decodeFromString<WebSocketTicketPayload>(body)
.ticket
.takeIf { it.isNotBlank() }
}.getOrNull()
}
@@ -9,7 +9,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
/**
* Coordinator for the issue #470 cookie stack.
@@ -22,9 +21,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
* - [getSessionCookie] / [setSessionCookie] — backward-compatible accessors
* used by [com.m57.hermescontrol.data.local.AuthManager] and the login flow.
*
* The session cookie is still exposed as a single string because the WS client
* and a few REST paths reference it directly; internally it is just the
* `hermes_session_at` cookie from the active scope.
* A single-value accessor remains for compatibility with migrated sessions;
* internally it resolves the dashboard access cookie from the active scope,
* including HTTPS prefix variants.
*/
object CookieManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -58,37 +57,36 @@ object CookieManager {
runBlocking(scope.coroutineContext) { j.useStore(serverId) }
}
/** Read the current `hermes_session_at` cookie value (or null). */
/** Read the current dashboard access-cookie value (or null). */
fun getSessionCookie(): String? = jar?.getSessionCookieValue()
/**
* Set the `hermes_session_at` session cookie for the active scope.
* [host] should be the dashboard host so the cookie matches REST requests.
* Set the session cookie for the active scope and canonical endpoint.
* HTTPS endpoints produce Secure cookies that cannot be sent over HTTP.
*/
fun setSessionCookie(
rawValue: String?,
host: String,
endpoint: ServerEndpoint,
) {
val j = jar ?: return
if (rawValue.isNullOrBlank()) {
// Clearing: drop the session cookie (and any other cookies) for the
// active scope — equivalent to the old AuthManager nulling the
// session-cookie pref.
j.clearActive()
return
}
val cookie =
val builder =
Cookie
.Builder()
.name(SESSION_COOKIE_NAME)
.value(rawValue)
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain(host)
.path("/")
.expiresAt(
System.currentTimeMillis() +
10L * 365 * 24 * 60 * 60 * 1000,
)
.hostOnlyDomain(endpoint.baseUrl.host)
.path(endpoint.baseUrl.encodedPath)
.httpOnly()
.build()
val url = "http://$host/".toHttpUrl()
j.saveFromResponse(url, listOf(cookie))
if (endpoint.baseUrl.isHttps) builder.secure()
j.saveFromResponse(endpoint.baseUrl, listOf(builder.build()))
}
/** Evict expired (non-session) cookies for the active scope. */
@@ -0,0 +1,164 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** Policy applied when a complete dashboard base URL uses cleartext HTTP. */
enum class CleartextPolicy {
DENY,
ALLOW_WITH_WARNING,
}
/**
* Canonical representation of a Hermes dashboard endpoint.
*
* The base URL always has an HTTP(S) scheme and a trailing slash. All REST,
* authentication, cookie, and WebSocket URLs are derived from this value so a
* reverse-proxy path prefix cannot be dropped by an individual caller.
*/
class ServerEndpoint private constructor(
val baseUrl: HttpUrl,
val securityWarning: String?,
) {
val isCleartext: Boolean
get() = baseUrl.scheme == "http"
fun resolve(relativePath: String): HttpUrl {
val path = relativePath.trim().trimStart('/')
require(path.isNotEmpty()) { "Endpoint path must not be blank" }
require(path.split('/').none { it == ".." }) {
"Endpoint path must not escape the configured base path"
}
return baseUrl.newBuilder().addPathSegments(path).build()
}
fun webSocketUrl(
authParameter: String,
credential: String,
): String {
require(authParameter == "token" || authParameter == "ticket") {
"Unsupported WebSocket authentication parameter"
}
val socketHttpUrl =
resolve("api/ws")
.newBuilder()
.addQueryParameter(authParameter, credential)
.build()
val socketScheme = if (baseUrl.isHttps) "wss" else "ws"
return replaceScheme(socketHttpUrl.toString(), socketScheme)
}
/** Return a request path relative to this endpoint's proxy prefix. */
fun relativeRequestPath(requestUrl: HttpUrl): String {
if (!sameOrigin(requestUrl)) return requestUrl.encodedPath
val prefix = baseUrl.encodedPath
if (!requestUrl.encodedPath.startsWith(prefix)) {
return requestUrl.encodedPath
}
return "/" + requestUrl.encodedPath.removePrefix(prefix)
}
private fun sameOrigin(other: HttpUrl): Boolean =
baseUrl.scheme == other.scheme &&
baseUrl.host == other.host &&
baseUrl.port == other.port
companion object {
const val DEFAULT_BASE_URL = "https://127.0.0.1:9119/"
const val CLEARTEXT_WARNING =
"Cleartext HTTP exposes credentials and messages. " +
"Use HTTPS unless this is a trusted development network."
fun parse(
rawValue: String,
cleartextPolicy: CleartextPolicy = CleartextPolicy.DENY,
): ServerEndpoint {
val raw = rawValue.trim()
require(raw.isNotEmpty()) { "Base URL is required" }
require(
raw.startsWith("https://", ignoreCase = true) ||
raw.startsWith("http://", ignoreCase = true),
) { "Base URL must use https:// or http://" }
val parsed =
raw.toHttpUrlOrNull()
?: throw IllegalArgumentException("Malformed base URL")
require(parsed.username.isEmpty() && parsed.password.isEmpty()) {
"Base URL must not contain credentials"
}
require(parsed.encodedQuery == null) {
"Base URL must not contain a query"
}
require(parsed.encodedFragment == null) {
"Base URL must not contain a fragment"
}
val normalized =
if (parsed.encodedPath.endsWith('/')) {
parsed
} else {
parsed.newBuilder().addPathSegment("").build()
}
if (!normalized.isHttps && cleartextPolicy == CleartextPolicy.DENY) {
throw IllegalArgumentException(
"Cleartext HTTP is disabled in this build. Use HTTPS.",
)
}
val warning = if (normalized.isHttps) null else CLEARTEXT_WARNING
return ServerEndpoint(normalized, warning)
}
fun parseForBuild(rawValue: String): ServerEndpoint =
parse(
rawValue,
if (BuildConfig.ALLOW_CLEARTEXT) {
CleartextPolicy.ALLOW_WITH_WARNING
} else {
CleartextPolicy.DENY
},
)
/** Preserve the pre-migration HTTP behavior for an existing install. */
fun fromLegacy(
host: String,
port: Int,
): ServerEndpoint {
require(port in 1..65535) { "Legacy port is out of range" }
val normalizedHost =
host.trim().removePrefix("[").removeSuffix("]")
val url =
HttpUrl
.Builder()
.scheme("http")
.host(normalizedHost)
.port(port)
.build()
return parse(
url.toString(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
}
fun redactWebSocketUrlForLog(rawUrl: String): String {
val httpScheme =
when {
rawUrl.startsWith("wss://", ignoreCase = true) -> "https"
rawUrl.startsWith("ws://", ignoreCase = true) -> "http"
else -> return "<invalid-websocket-url>"
}
val socketScheme = if (httpScheme == "https") "wss" else "ws"
val converted =
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
?: return "<invalid-websocket-url>"
val redacted = converted.newBuilder().query(null).build().toString()
return replaceScheme(redacted, socketScheme)
}
private fun replaceScheme(
url: String,
scheme: String,
): String = url.replaceBefore("://", scheme)
}
}
@@ -265,7 +265,7 @@ object HermesWsClient {
val request =
Request
.Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody())
.build()
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -153,27 +154,35 @@ fun AuthLoginScreen(
Spacer(modifier = Modifier.height(8.dp))
}
// Host field
// Server URL field
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.auth_login_host_label)) },
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = { Text(stringResource(R.string.auth_login_base_url_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
enabled = state.authMode == null,
)
// Port field
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.auth_login_port_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = state.authMode == null,
)
// Cleartext transport warning
AnimatedVisibility(
visible = state.transportWarning != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.transportWarning?.let { warning ->
Text(
text = warning,
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.error,
),
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
// Probe / probing indicator
if (state.probing) {
@@ -261,6 +270,27 @@ fun AuthLoginScreen(
)
}
// OAuth "coming soon" notice — shown for OAUTH mode (issue #639)
AnimatedVisibility(
visible = state.authMode == DashboardAuthMode.OAUTH,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.auth_login_oauth_coming_soon),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
textAlign = TextAlign.Start,
)
}
}
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
@@ -290,7 +320,7 @@ fun AuthLoginScreen(
}
},
modifier = Modifier.fillMaxWidth().height(52.dp),
enabled = !state.isLoading && !state.probing,
enabled = !state.isLoading && !state.probing && state.authMode != DashboardAuthMode.OAUTH,
) {
if (state.isLoading) {
CircularProgressIndicator(
@@ -9,6 +9,10 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.AuthPayloads
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Dispatchers
@@ -18,28 +22,40 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* What auth the dashboard requires.
* What auth the dashboard requires. Derived authoritatively from
* `GET /api/status` (`auth_required` + `auth_providers`), not by sniffing
* redirects — the status JSON is the source of truth.
*/
enum class DashboardAuthMode {
/** Dashboard has no auth gate — just needs a session token. */
/** Dashboard has no auth gate (loopback / `--insecure`) — just needs a session token. */
TOKEN_ONLY,
/** Dashboard has basic auth — needs username + password (and possibly a token). */
/** Dashboard has basic auth (gated `0.0.0.0` bind) — needs username + password. */
BASIC_AUTH,
/** Dashboard requires both basic auth credentials and a session token. */
ALL,
/**
* Dashboard uses OAuth via Nous Portal (gated `0.0.0.0` bind).
*
* TODO: implement the OAuth browser flow + WS ticket minting. Until then the
* UI renders a "coming soon" state and blocks connect. Tracked in issue #639.
*/
OAUTH,
}
data class AuthLoginUiState(
val host: String = "127.0.0.1",
val port: String = "9119",
val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val transportWarning: String? = null,
val token: String = "",
val username: String = "",
val password: String = "",
@@ -57,8 +73,7 @@ class AuthLoginViewModel(
private val _uiState =
MutableStateFlow(
AuthLoginUiState(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
baseUrl = AuthManager.getBaseUrl(),
),
)
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
@@ -91,8 +106,13 @@ class AuthLoginViewModel(
private val probeClient: OkHttpClient =
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) }
fun onBaseUrlChange(value: String) {
val trimmed = value.trim()
val warning =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null, authMode = null) }
}
/** Reset ephemeral connection state (called when screen leaves composition). */
@@ -100,16 +120,6 @@ class AuthLoginViewModel(
_uiState.update { it.copy(connectionSuccess = false, errorMessage = null, isLoading = false) }
}
fun onPortChange(value: String) {
_uiState.update {
it.copy(
port = value.filter { c -> c.isDigit() },
errorMessage = null,
authMode = null,
)
}
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
}
@@ -124,16 +134,18 @@ class AuthLoginViewModel(
/**
* Step 1: Probe the dashboard to detect what auth it needs.
*
* Uses the public `GET /api/status` endpoint as the authoritative source:
* it reports `auth_required` (gate engaged on non-loopback binds) and
* `auth_providers` (e.g. ["basic"], ["oauth"], []). We map that to a
* [DashboardAuthMode] instead of sniffing redirects, which is a heuristic.
*/
fun probe() {
val state = _uiState.value
if (state.host.isBlank()) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_host_required)) }
return
}
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_port_invalid)) }
val endpoint =
runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
if (endpoint == null) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_url_invalid)) }
return
}
@@ -142,7 +154,7 @@ class AuthLoginViewModel(
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
probeDashboardInternal(state.host, port)
probeDashboardInternal(endpoint)
}
_uiState.update {
it.copy(
@@ -169,87 +181,99 @@ class AuthLoginViewModel(
)
/**
* Probes the dashboard to determine [DashboardAuthMode].
* Returns null if the dashboard is unreachable.
* When [ProbeResult.extractedToken] is non-null, the session token
* was found embedded in the dashboard SPA HTML and can be auto-populated.
* Derives the [DashboardAuthMode] from the authoritative `/api/status` fields.
*
* - Gate down (loopback / `--insecure`): caller decides TOKEN_ONLY vs ALL
* based on whether the SPA embedded a token, so this returns a sentinel
* [DashboardAuthMode.ALL] placeholder that [probeDashboardInternal] refines.
* - Gate up (non-loopback): pick from the provider list (oauth > basic).
*
* Internal + pure so it is unit-testable without a live server.
*/
private fun probeDashboardInternal(
host: String,
port: Int,
): ProbeResult? {
val baseUrl = "http://$host:$port"
internal fun deriveAuthMode(
authRequired: Boolean,
providers: List<String>,
): DashboardAuthMode =
if (!authRequired) {
// Refined by the caller once it knows whether the SPA embedded a token.
DashboardAuthMode.ALL
} else {
when {
providers.contains("oauth") -> DashboardAuthMode.OAUTH
providers.contains("basic") -> DashboardAuthMode.BASIC_AUTH
else -> DashboardAuthMode.BASIC_AUTH // gate up, unknown provider → basic fallback
}
}
// Step 1: Check if dashboard is reachable via /api/status (always public)
val statusOk =
/**
* Probes `GET /api/status` and derives the [DashboardAuthMode] from the
* authoritative `auth_required` + `auth_providers` fields.
*
* Returns null if the dashboard is unreachable. When [ProbeResult.extractedToken]
* is non-null, the session token was found embedded in the dashboard SPA HTML
* (loopback mode only) and can be auto-populated.
*/
private fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? {
// Step 1: reachability + auth mode from the public status endpoint.
val statusJson =
try {
val req =
Request
.Builder()
.url("$baseUrl/api/status")
.url(endpoint.resolve("api/status").toString())
.get()
.build()
val resp = probeClient.newCall(req).execute()
resp.isSuccessful
if (!resp.isSuccessful) return null
resp.body.string()
} catch (e: Exception) {
Log.w(TAG, "Status probe failed: ${e.message}")
return null // Dashboard unreachable
}
if (!statusOk) return null
val authRequired: Boolean
val providers: List<String>
try {
val node = OkHttpProvider.json.parseToJsonElement(statusJson).jsonObject
authRequired = node["auth_required"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false
providers =
node["auth_providers"]
?.jsonArray
?.mapNotNull { it.jsonPrimitive.content }
.orEmpty()
} catch (e: Exception) {
Log.w(TAG, "Status parse failed: ${e.message}")
return null
}
// Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA
val needsBasicAuth =
// No gate (loopback / --insecure): token mode. Try to grab the embedded token.
if (!authRequired) {
var extractedToken: String? = null
try {
val req =
val spaReq =
Request
.Builder()
.url(baseUrl)
.url(endpoint.resolve("").toString())
.get()
.build()
val resp = probeClient.newCall(req).execute()
val code = resp.code
val location = resp.header("location", "")
// 302 to /login means basic auth is active
code == 302 && location?.contains("/login", ignoreCase = true) == true
} catch (e: Exception) {
Log.w(TAG, "SPA probe failed: ${e.message}")
false
}
// Step 3: Extract token from SPA HTML (if reachable without redirect)
var extractedToken: String? = null
if (!needsBasicAuth) {
try {
val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute()
val body = resp.body.string()
// Extract __HERMES_SESSION_TOKEN__ from the SPA HTML
val spaResp = probeClient.newCall(spaReq).execute()
val body = spaResp.body.string()
val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body)
extractedToken = tokenMatch?.groupValues?.getOrNull(1)
if (extractedToken == null) {
Log.w(TAG, "SPA has no __HERMES_SESSION_TOKEN__ — mode might require both auth")
}
} catch (e: Exception) {
Log.w(TAG, "SPA token extraction failed: ${e.message}")
}
val mode =
if (extractedToken != null) {
DashboardAuthMode.TOKEN_ONLY
} else {
DashboardAuthMode.ALL
}
return ProbeResult(authMode = mode, extractedToken = extractedToken)
}
val authMode =
if (needsBasicAuth) {
DashboardAuthMode.BASIC_AUTH
} else if (extractedToken != null) {
DashboardAuthMode.TOKEN_ONLY
} else {
DashboardAuthMode.ALL
}
return ProbeResult(authMode = authMode, extractedToken = extractedToken)
// Gate engaged (non-loopback bind). Derive from the provider list.
return ProbeResult(authMode = deriveAuthMode(authRequired, providers))
}
/**
@@ -265,7 +289,9 @@ class AuthLoginViewModel(
*/
fun connect() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: return
val endpoint =
runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
?: return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -274,16 +300,27 @@ class AuthLoginViewModel(
withContext(Dispatchers.IO) {
when (state.authMode) {
DashboardAuthMode.TOKEN_ONLY -> {
val token = connectTokenOnly(state.host, port, state.token)
val token = connectTokenOnly(endpoint, state.token)
if (token != null) ConnectResult(wsCredential = token) else null
}
DashboardAuthMode.BASIC_AUTH -> {
connectBasicAuth(state.host, port, state.username, state.password)
connectBasicAuth(endpoint, state.username, state.password)
}
DashboardAuthMode.ALL -> {
connectBasicAuth(state.host, port, state.username, state.password)
connectBasicAuth(endpoint, state.username, state.password)
}
DashboardAuthMode.OAUTH -> {
// TODO(issue #639): implement OAuth browser flow + WS ticket minting.
_uiState.update {
it.copy(
isLoading = false,
errorMessage = app.getString(R.string.auth_login_error_oauth_unsupported),
)
}
null
}
null -> {
@@ -293,8 +330,7 @@ class AuthLoginViewModel(
}
if (result != null) {
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setBaseUrl(state.baseUrl)
AuthManager.setToken(result.wsCredential)
if (state.authMode == DashboardAuthMode.TOKEN_ONLY) {
// Loopback mode — no session cookie; ensure any stale one
@@ -319,8 +355,7 @@ class AuthLoginViewModel(
* Validate the token by calling /api/status with it.
*/
private suspend fun connectTokenOnly(
host: String,
port: Int,
endpoint: ServerEndpoint,
token: String,
): String? {
if (token.isBlank()) {
@@ -330,7 +365,7 @@ class AuthLoginViewModel(
return null
}
val tempApi = ApiClient.createTempService(host, port, token)
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), token)
val result = safeApiCall { tempApi.getSessions() }
return when (result) {
@@ -372,8 +407,7 @@ class AuthLoginViewModel(
* Returns the WS ticket and the session cookie for REST auth.
*/
private fun connectBasicAuth(
host: String,
port: Int,
endpoint: ServerEndpoint,
username: String,
password: String,
): ConnectResult? {
@@ -390,26 +424,22 @@ class AuthLoginViewModel(
return null
}
val baseUrl = "http://$host:$port"
val jsonBody = """{"provider":"basic","username":"$username","password":"$password","next":""}"""
val jsonBody = AuthPayloads.passwordLogin(username, password)
try {
// Step 1: Authenticate via the password login endpoint to get a session cookie
val loginClient =
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
// Step 1: Authenticate via the password login endpoint to get a session cookie.
// IMPORTANT: use the SHARED OkHttpProvider.probe client (it carries the
// persistent CookieManager.cookieJar). Do NOT build a fresh client here —
// a separate client would not share the jar, so the Set-Cookie from this
// login would never reach the ws-ticket request below and auth would fail.
val loginReq =
Request
.Builder()
.url("$baseUrl/auth/password-login")
.url(endpoint.resolve("auth/password-login").toString())
.header("Content-Type", "application/json")
.post(jsonBody.toRequestBody())
.build()
val loginResp = loginClient.newCall(loginReq).execute()
val loginResp = OkHttpProvider.probe.newCall(loginReq).execute()
if (!loginResp.isSuccessful) {
val msg =
@@ -421,27 +451,18 @@ class AuthLoginViewModel(
_uiState.update { it.copy(isLoading = false, errorMessage = msg) }
return null
}
// The session cookie is captured automatically by the shared
// CookieJar (issue #470) attached to OkHttpProvider.probe — no
// manual Set-Cookie parsing needed. We still mint a WS ticket below
// using whatever cookie the jar carries into that request.
// The session cookie is now captured in the shared CookieManager.cookieJar
// (issue #470). The ws-ticket request below uses the same jar, so the
// authenticated session carries over automatically.
// Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient =
com.m57.hermescontrol.data.remote.OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val ticketReq =
Request
.Builder()
.url("$baseUrl/api/auth/ws-ticket")
.url(endpoint.resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody())
.build()
val ticketResp = ticketClient.newCall(ticketReq).execute()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
if (!ticketResp.isSuccessful) {
_uiState.update {
@@ -257,7 +257,19 @@ class ChatViewModel(
// ── Connection ───────────────────────────────────────────────────────
private fun connectWebSocket(setLoading: Boolean = false) {
val token = AuthManager.getToken() ?: return
// In loopback (token) mode the session token is the WS credential and
// must be present before connecting. In gated (ticket) mode the ticket
// is minted fresh by HermesWsClient.refreshWsTicketIfNeeded() from the
// persisted session cookie, so getToken() is expected to be empty here
// and must NOT block the connect (issue #640: chat showed "reconnect"
// immediately after basic-auth login because this guard returned early).
val isGated =
runCatching { AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" }
.getOrNull() ?: false
if (!isGated) {
val token = AuthManager.getToken() ?: return
if (token.isBlank()) return
}
// Don't disturb an already-working (or already-recovering) connection.
// HermesWsClient is a global singleton shared by every tab; the chat tab
@@ -172,23 +172,6 @@ fun ConnectScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.connect_pairing_string),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
var pairingString by remember { mutableStateOf("") }
OutlinedTextField(
value = pairingString,
onValueChange = {
pairingString = it
if (it.isNotBlank()) viewModel.onPairingString(it)
},
placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
@@ -275,6 +258,31 @@ fun ConnectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider()
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = { Text(stringResource(R.string.connect_server_url_label)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
// Cleartext transport warning
AnimatedVisibility(
visible = state.transportWarning != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.transportWarning?.let { warning ->
Text(
text = warning,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
@@ -307,23 +315,6 @@ fun ConnectScreen(
},
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -8,9 +8,10 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,13 +20,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class ConnectUiState(
val token: String = "",
val host: String = "127.0.0.1",
val port: String = "9119",
val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val transportWarning: String? = null,
val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false,
val errorMessage: String? = null,
@@ -47,16 +46,14 @@ class ConnectViewModel(
fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
val savedPort = AuthManager.getPort()
val savedBaseUrl = AuthManager.getBaseUrl()
val profiles = AuthManager.getConnectionProfiles()
val selectedId = AuthManager.getSelectedProfileId()
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
_uiState.update {
it.copy(
token = savedToken,
host = selectedProfile?.host ?: savedHost,
port = (selectedProfile?.port ?: savedPort).toString(),
baseUrl = selectedProfile?.resolvedBaseUrl ?: savedBaseUrl,
profiles = profiles,
selectedProfile = selectedProfile,
profileName = selectedProfile?.name ?: "",
@@ -79,8 +76,7 @@ class ConnectViewModel(
it.copy(
selectedProfile = profile,
profileName = profile.name,
host = profile.host,
port = profile.port.toString(),
baseUrl = profile.resolvedBaseUrl,
token = token,
)
}
@@ -90,12 +86,27 @@ class ConnectViewModel(
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
}
fun onBaseUrlChange(value: String) {
val trimmed = value.trim()
val warning =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null) }
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null) }
val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().host(value.trim()).build().toString()
onBaseUrlChange(next)
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) }
val portText = value.filter { c -> c.isDigit() }
val port = portText.toIntOrNull() ?: return
val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().port(port).build().toString()
onBaseUrlChange(next)
}
fun connect() {
@@ -104,13 +115,10 @@ class ConnectViewModel(
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) }
return
}
if (state.host.isBlank()) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) }
return
}
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) }
val endpoint =
runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
if (endpoint == null) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_url_invalid)) }
return
}
@@ -119,17 +127,14 @@ class ConnectViewModel(
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(state.host, port, state.token)
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
safeApiCall { tempApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
// Persist credentials to the selected (Default) profile upon successful verification.
// setToken/setHost/setPort delegate to the currently-selected profile, which is
// always non-null after issue #478 (never the legacy standalone path).
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setBaseUrl(state.baseUrl)
ApiClient.rebuild()
if (state.saveProfile) {
@@ -143,12 +148,11 @@ class ConnectViewModel(
}
val targetProfile =
if (existingIndex >= 0) {
currentProfiles[existingIndex].copy(host = state.host, port = port)
currentProfiles[existingIndex].copy(baseUrl = state.baseUrl)
} else {
ConnectionProfile(
name = state.profileName,
host = state.host,
port = port,
baseUrl = state.baseUrl,
)
}
val updatedProfiles =
@@ -274,99 +278,6 @@ class ConnectViewModel(
}
}
}
fun onPairingString(value: String) {
val trimmed = value.trim()
if (trimmed.isBlank()) return
try {
if (trimmed.startsWith("hermes://connect?", ignoreCase = true)) {
val uri = android.net.Uri.parse(trimmed)
val host = uri.getQueryParameter("host")
val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) {
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
}
// Try decoding as Base64 JSON
try {
val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
val host = json["host"]?.jsonPrimitive?.content
val port = json["port"]?.jsonPrimitive?.content
val token = json["token"]?.jsonPrimitive?.content
if (host != null && port != null && token != null) {
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
// Decoded valid JSON but missing required fields
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_missing_fields),
)
}
return
}
// Decoded to valid string but not JSON shape — not a pairing string
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_malformed),
)
}
return
} catch (e: IllegalArgumentException) {
// Not valid Base64 — check if input looks like a raw token
if (trimmed.length >= 32 && trimmed.matches(Regex("[A-Za-z0-9_\\-]+"))) {
_uiState.update { it.copy(token = trimmed, errorMessage = null) }
} else {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_malformed),
)
}
}
return
} catch (e: Exception) {
_uiState.update {
it.copy(
errorMessage =
String.format(
app.getString(R.string.connect_error_parse_failed),
e.message ?: "",
),
)
}
return
}
} catch (e: Exception) {
_uiState.update {
it.copy(
errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""),
)
}
}
}
}
class ConnectViewModelFactory(
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -128,7 +129,11 @@ class PairingCodeEntryViewModel(
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(host, port, token)
val tempApi =
ApiClient.createTempService(
ServerEndpoint.fromLegacy(host, port).baseUrl.toString(),
token,
)
safeApiCall { tempApi.getStatus() }
}
@@ -227,10 +227,7 @@ internal fun InfoRow(
}
@Composable
internal fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
internal fun SectionCard(content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -240,15 +237,6 @@ internal fun SectionCard(
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
@@ -6,7 +6,9 @@ import com.m57.hermescontrol.ScreenRegistry
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode
@@ -22,8 +24,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SettingsUiState(
val host: String = "127.0.0.1",
val port: String = "9119",
val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val transportWarning: String? = null,
val token: String = "",
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
@@ -44,9 +46,9 @@ data class SettingsUiState(
val showProfileDialog: Boolean = false,
val editingProfileId: String? = null,
val dialogProfileName: String = "",
val dialogProfileHost: String = "",
val dialogProfilePort: String = "",
val dialogProfileBaseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val dialogProfileToken: String = "",
val dialogProfileError: String? = null,
// Delete confirmation
val showDeleteConfirm: Boolean = false,
val profileToDeleteId: String? = null,
@@ -68,8 +70,7 @@ class SettingsViewModel(
private suspend fun loadSettings() {
val selectedId = AuthManager.getSelectedProfileId()
val host = AuthManager.getHost()
val port = AuthManager.getPort().toString()
val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken() ?: ""
val autoReconnect = AuthManager.isAutoReconnect()
val themePreference = AuthManager.getThemePreference()
@@ -83,10 +84,14 @@ class SettingsViewModel(
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
val transportWarning =
runCatching {
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update {
it.copy(
host = host,
port = port,
baseUrl = baseUrl,
transportWarning = transportWarning,
token = token,
autoReconnect = autoReconnect,
themePreference = themePreference,
@@ -150,9 +155,9 @@ class SettingsViewModel(
showProfileDialog = true,
editingProfileId = null,
dialogProfileName = "",
dialogProfileHost = "127.0.0.1",
dialogProfilePort = "9119",
dialogProfileBaseUrl = ServerEndpoint.DEFAULT_BASE_URL,
dialogProfileToken = "",
dialogProfileError = null,
)
}
}
@@ -165,29 +170,28 @@ class SettingsViewModel(
showProfileDialog = true,
editingProfileId = profileId,
dialogProfileName = profile.name,
dialogProfileHost = profile.host,
dialogProfilePort = profile.port.toString(),
dialogProfileBaseUrl = profile.resolvedBaseUrl,
dialogProfileToken = token,
dialogProfileError = null,
)
}
}
fun closeProfileDialog() {
_uiState.update {
it.copy(showProfileDialog = false, editingProfileId = null)
}
_uiState.update { it.copy(showProfileDialog = false, editingProfileId = null) }
}
fun onDialogProfileNameChange(value: String) {
_uiState.update { it.copy(dialogProfileName = value) }
}
fun onDialogProfileHostChange(value: String) {
_uiState.update { it.copy(dialogProfileHost = value.trim()) }
}
fun onDialogProfilePortChange(value: String) {
_uiState.update { it.copy(dialogProfilePort = value.filter { c -> c.isDigit() }) }
fun onDialogProfileBaseUrlChange(value: String) {
val trimmed = value.trim()
val error =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING)
}.exceptionOrNull()?.message
_uiState.update { it.copy(dialogProfileBaseUrl = trimmed, dialogProfileError = error) }
}
fun onDialogProfileTokenChange(value: String) {
@@ -197,11 +201,14 @@ class SettingsViewModel(
fun saveProfileFromDialog() {
val state = _uiState.value
val name = state.dialogProfileName.trim()
val host = state.dialogProfileHost.trim()
val port = state.dialogProfilePort.toIntOrNull() ?: return
val token = state.dialogProfileToken
val baseUrl = state.dialogProfileBaseUrl.trim()
if (name.isBlank() || host.isBlank()) return
if (name.isBlank()) return
val normalized =
runCatching {
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
}.getOrNull() ?: return
val token = state.dialogProfileToken
val profiles = AuthManager.getConnectionProfiles().toMutableList()
val editingId = state.editingProfileId
@@ -211,7 +218,7 @@ class SettingsViewModel(
val index = profiles.indexOfFirst { it.id == editingId }
if (index == -1) return
val oldToken = AuthManager.getProfileToken(editingId)
profiles[index] = profiles[index].copy(name = name, host = host, port = port)
profiles[index] = profiles[index].copy(name = name, baseUrl = normalized)
AuthManager.saveConnectionProfiles(profiles)
if (token != oldToken) {
AuthManager.setProfileToken(editingId, token)
@@ -221,8 +228,7 @@ class SettingsViewModel(
val newProfile =
ConnectionProfile(
name = name,
host = host,
port = port,
baseUrl = normalized,
)
profiles.add(newProfile)
AuthManager.saveConnectionProfiles(profiles)
@@ -267,12 +273,13 @@ class SettingsViewModel(
}
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) }
fun onBaseUrlChange(value: String) {
val trimmed = value.trim()
val transportWarning =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = transportWarning, isSaved = false) }
}
fun onTokenChange(value: String) {
@@ -358,10 +365,12 @@ class SettingsViewModel(
fun save() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119
val normalized =
runCatching {
ServerEndpoint.parse(state.baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
}.getOrNull() ?: ServerEndpoint.DEFAULT_BASE_URL
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setBaseUrl(normalized)
AuthManager.setToken(state.token)
AuthManager.setAutoReconnect(state.autoReconnect)
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
@@ -24,7 +24,7 @@ import com.m57.hermescontrol.ui.settings.SectionCard
@Composable
internal fun AboutSection(onLogout: () -> Unit) {
SectionCard(title = stringResource(R.string.settings_sec_about)) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.settings_about_app_name),
@@ -57,7 +57,7 @@ internal fun AppearanceSection(
appLanguage: String,
onAppLanguageChange: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
SectionCard {
Text(
text = stringResource(R.string.settings_item_theme),
style = MaterialTheme.typography.bodyLarge,
@@ -243,7 +243,7 @@ internal fun ChatSection(
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
SectionCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -326,7 +326,7 @@ internal fun NavBarSection(
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
SectionCard {
Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
style = MaterialTheme.typography.bodyLarge,
@@ -22,7 +22,7 @@ internal fun BehaviorSection(
autoReconnect: Boolean,
onAutoReconnectChange: (Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
SectionCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -46,7 +46,7 @@ internal fun ConnectionSection(
passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
SectionCard {
// ── Saved profiles list ──────────────────────────────────
if (state.profiles.isNotEmpty()) {
Text(
@@ -85,7 +85,7 @@ internal fun ConnectionSection(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
Text(
text = "${profile.host}:${profile.port}",
text = profile.resolvedBaseUrl,
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -139,12 +139,11 @@ internal fun ConnectionSection(
ProfileEditorDialog(
isEditing = state.editingProfileId != null,
name = state.dialogProfileName,
host = state.dialogProfileHost,
port = state.dialogProfilePort,
baseUrl = state.dialogProfileBaseUrl,
token = state.dialogProfileToken,
error = state.dialogProfileError,
onNameChange = viewModel::onDialogProfileNameChange,
onHostChange = viewModel::onDialogProfileHostChange,
onPortChange = viewModel::onDialogProfilePortChange,
onBaseUrlChange = viewModel::onDialogProfileBaseUrlChange,
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
@@ -25,12 +25,11 @@ import com.m57.hermescontrol.R
internal fun ProfileEditorDialog(
isEditing: Boolean,
name: String,
host: String,
port: String,
baseUrl: String,
token: String,
error: String?,
onNameChange: (String) -> Unit,
onHostChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onBaseUrlChange: (String) -> Unit,
onTokenChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
@@ -55,20 +54,22 @@ internal fun ProfileEditorDialog(
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = host,
onValueChange = onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
value = baseUrl,
onValueChange = onBaseUrlChange,
label = { Text(stringResource(R.string.connect_server_url_label)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
singleLine = true,
isError = error != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port,
onValueChange = onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
if (isEditing) {
OutlinedTextField(
value = token,
@@ -87,7 +88,7 @@ internal fun ProfileEditorDialog(
}
},
confirmButton = {
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) {
Button(onClick = onSave, enabled = name.isNotBlank() && error == null) {
Text(stringResource(R.string.action_save))
}
},