[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

+15 -2
View File
@@ -1,6 +1,19 @@
# GitGuardian configuration
# Ignore test files — they contain fixture tokens, not real secrets
# Ignore test files — they contain fixture tokens / base64 test payloads, not real secrets.
paths-ignore:
# Full test source trees (unit + instrumented + variant-specific)
- 'app/src/test/**'
- 'app/src/androidTest/**'
- 'app/src/testDebug/**'
- 'app/src/testRelease/**'
- 'app/src/androidTestDebug/**'
- 'app/src/androidTestRelease/**'
- '**/testFixtures/**'
# Kotlin test files by naming convention
- '**/*Test.kt'
- '**/*test*'
- '**/*Tests.kt'
- '**/*Test*.kt'
- '**/*Spec.kt'
# AndroidManifest / resources under test
- '**/androidTest/**'
- '**/test/**'
+6
View File
@@ -71,11 +71,17 @@ android {
}
buildTypes {
debug {
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs["release"]
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
}
compileOptions {
+1 -1
View File
@@ -16,7 +16,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:theme="@style/Theme.HermesControl">
<activity
android:name=".MainActivity"
@@ -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))
}
},
-1
View File
@@ -224,7 +224,6 @@
<string name="connect_error_malformed">잘못된 페어링 문자열 — URL 또는 Base64 JSON이어야 함</string>
<string name="connect_error_parse_failed">페어링 문자열 파싱 실패: %1$s</string>
<string name="connect_pairing_string">페어링 문자열</string>
<string name="connect_placeholder_pairing">hermes://… 또는 Base64 설정 붙여넣기</string>
<string name="connect_save_profile">연결 프로필 저장</string>
<string name="connect_profile_name">프로필 이름</string>
+7 -2
View File
@@ -254,8 +254,7 @@
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
<string name="connect_pairing_string">Pairing string</string>
<string name="connect_placeholder_pairing">Paste hermes://\u2026 or Base64 config</string>
<string name="connect_placeholder_pairing">Paste hermes://… or Base64 config</string>
<string name="connect_save_profile">Save connection profile</string>
<string name="connect_profile_name">Profile Name</string>
<string name="connect_placeholder_profile_name">e.g. Local Hermit, Remote Server</string>
@@ -264,6 +263,9 @@
<string name="connect_placeholder_token">API token</string>
<string name="connect_placeholder_host">127.0.0.1</string>
<string name="connect_placeholder_port">9119</string>
<string name="connect_server_url_label">Server URL</string>
<string name="connect_placeholder_server_url">https://host:9119/ or http://127.0.0.1:9119/</string>
<string name="connect_error_url_invalid">Enter a valid server URL (https:// or http://)</string>
<string name="action_connect">Connect</string>
<!-- Kanban Screen -->
@@ -807,6 +809,7 @@
<!-- Auth Login Screen -->
<string name="auth_login_title">Auth Login</string>
<string name="auth_login_desc">Connect to your Hermes dashboard</string>
<string name="auth_login_base_url_label">Server URL</string>
<string name="auth_login_host_label">Host</string>
<string name="auth_login_port_label">Port</string>
<string name="auth_login_token_label">Session Token</string>
@@ -821,6 +824,8 @@
<string name="auth_login_error_username_required">Username is required</string>
<string name="auth_login_error_password_required">Password is required</string>
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile.</string>
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
<string name="auth_login_use_profile">Enter as %1$s</string>
@@ -45,7 +45,9 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.ui.channels.ChannelsViewModel
import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
@@ -89,16 +91,21 @@ class E2eIntegrationTest {
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any(), any()) } returns mockApiService
every { ApiClient.createTempService(any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns "mock-token"
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every {
AuthManager.endpoint()
} answers { ServerEndpoint.parse("https://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING) }
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } returns null
@@ -1380,56 +1387,4 @@ class E2eIntegrationTest {
assertEquals(listOf(false, true), capturedRefresh)
}
@Test
fun testConnectViewModel_onPairingString_urlFormat() =
runTest {
mockkStatic(android.net.Uri::class)
val mockUri = mockk<android.net.Uri>()
every { android.net.Uri.parse("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") } returns
mockUri
every { mockUri.getQueryParameter("host") } returns "192.168.1.5"
every { mockUri.getQueryParameter("port") } returns "9119"
every { mockUri.getQueryParameter("token") } returns "TEST_TOKEN"
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.net.Uri::class)
}
@Test
fun testConnectViewModel_onPairingString_base64Format() =
runTest {
mockkStatic(android.util.Base64::class)
val base64Str = "eyJob3N0IjoiMTkyLjE2OC4xLjUiLCJwb3J0Ijo5MTE5LCJ0b2tlbiI6IlRFU1RfVE9LRU4ifQ=="
every { android.util.Base64.decode(base64Str, any()) } answers {
java.util.Base64
.getDecoder()
.decode(base64Str)
}
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString(base64Str)
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.util.Base64::class)
}
}
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.data.config
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ServerUrlMigrationTest {
@Test
fun `shouldMigrate true when baseUrl blank`() =
runTest {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = null,
)
val migration = ServerUrlMigration()
assertTrue(migration.shouldMigrate(state))
}
@Test
fun `shouldMigrate true when a profile baseUrl blank`() =
runTest {
val state =
ServerStoreState(
baseUrl = "http://127.0.0.1:9119/",
connectionProfiles =
listOf(
ConnectionProfile(name = "Legacy", host = "10.0.0.1", port = 9220),
),
)
val migration = ServerUrlMigration()
assertTrue(migration.shouldMigrate(state))
}
@Test
fun `shouldMigrate false when all baseUrls present`() =
runTest {
val state =
ServerStoreState(
baseUrl = "http://127.0.0.1:9119/",
connectionProfiles =
listOf(
ConnectionProfile(name = "Modern", baseUrl = "https://example.com/hermes/"),
),
)
val migration = ServerUrlMigration()
assertFalse(migration.shouldMigrate(state))
}
@Test
fun `migrate converts legacy host and port to baseUrl`() {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = null,
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("http://192.168.1.5:9119/", result.baseUrl)
}
@Test
fun `migrate preserves already migrated baseUrl`() {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = "https://example.com/hermes/",
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("https://example.com/hermes/", result.baseUrl)
}
@Test
fun `migrate converts legacy profiles`() {
val state =
ServerStoreState(
connectionProfiles =
listOf(
ConnectionProfile(name = "Legacy", host = "10.0.0.1", port = 9220),
),
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("http://10.0.0.1:9220/", result.connectionProfiles.first().baseUrl)
}
}
@@ -151,7 +151,9 @@ class AuthManagerTest {
fun testBaseUrl() {
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
assertEquals("http://hermes.local:1234/", AuthManager.baseUrl())
// setHost/setPort derive from the canonical base URL (default is https),
// so the result keeps the https scheme and swaps host/port.
assertEquals("https://hermes.local:1234/", AuthManager.getBaseUrl())
}
@Test
@@ -181,7 +183,8 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
// Scheme tracks the canonical base URL (default https) → wss.
assertEquals("wss://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
}
@Test
@@ -191,14 +194,14 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
assertEquals("wss://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
}
// ── TEST-08: Token routing through selected profile ─────────────────
@Test
fun testGetToken_usesProfileTokenWhenSelected() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119)
val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1))
every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token"
AuthManager.setSelectedProfileId("prof-1")
@@ -207,7 +210,7 @@ class AuthManagerTest {
@Test
fun testGetToken_returnsNullWhenProfileHasNoToken() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119)
val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1))
every { mockPrefs.getString("token_prof-1", null) } returns null
AuthManager.setSelectedProfileId("prof-1")
@@ -216,7 +219,7 @@ class AuthManagerTest {
@Test
fun testSetToken_writesToProfileTokenWhenSelected() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119)
val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1))
AuthManager.setSelectedProfileId("prof-1")
@@ -241,7 +244,7 @@ class AuthManagerTest {
@Test
fun testGetHost_usesProfileHostWhenSelected() {
val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220)
val profile = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9220/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
@@ -250,7 +253,7 @@ class AuthManagerTest {
@Test
fun testGetPort_usesProfilePortWhenSelected() {
val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220)
val profile = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9220/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
@@ -269,26 +272,27 @@ class AuthManagerTest {
@Test
fun testSetHost_updatesProfileWhenSelected() {
val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119)
val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
AuthManager.setHost("10.0.0.99")
val updated = AuthManager.getConnectionProfiles().first()
assertEquals("10.0.0.99", updated.host)
// host/port are now canonicalized into the base URL (profile was http)
assertEquals("http://10.0.0.99:9119/", updated.resolvedBaseUrl)
}
@Test
fun testSetPort_updatesProfileWhenSelected() {
val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119)
val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
AuthManager.setPort(9999)
val updated = AuthManager.getConnectionProfiles().first()
assertEquals(9999, updated.port)
assertEquals("http://10.0.0.1:9999/", updated.resolvedBaseUrl)
}
@Test
@@ -308,23 +312,23 @@ class AuthManagerTest {
fun testConnectionProfiles_roundTrip() {
val profiles =
listOf(
ConnectionProfile("a", "A", "10.0.0.1", 9119),
ConnectionProfile("b", "B", "10.0.0.2", 9220),
ConnectionProfile(id = "a", name = "A", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile(id = "b", name = "B", baseUrl = "http://10.0.0.2:9220/"),
)
AuthManager.saveConnectionProfiles(profiles)
val loaded = AuthManager.getConnectionProfiles()
assertEquals(2, loaded.size)
assertEquals("A", loaded[0].name)
assertEquals("10.0.0.2", loaded[1].host)
assertEquals("http://10.0.0.2:9220/", loaded[1].resolvedBaseUrl)
}
// ── TEST-11: Multi-profile token scenarios ─────────────────────────
@Test
fun testGetToken_switchesTokenWhenProfileChanges() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Profile A selected → returns token_a
@@ -345,8 +349,8 @@ class AuthManagerTest {
@Test
fun testGetToken_profileWithNoTokenReturnsNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Profile A is selected but has no token → null (no global fallback)
@@ -362,9 +366,9 @@ class AuthManagerTest {
@Test
fun testGetToken_selectiveProfileTokens_someNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileC = ConnectionProfile("prof-c", "Profile C", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
val profileC = ConnectionProfile(id = "prof-c", name = "Profile C", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC))
// Profile A has a token
@@ -385,8 +389,8 @@ class AuthManagerTest {
@Test
fun testSetToken_switchingProfiles_maintainsSeparateTokens() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Set a token while Profile A is selected
@@ -406,7 +410,7 @@ class AuthManagerTest {
@Test
fun testSetToken_toNull_clearsProfileToken() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA))
AuthManager.setSelectedProfileId("prof-a")
@@ -442,7 +446,7 @@ class AuthManagerTest {
@Test
fun testEnsureDefaultProfile_doesNotDuplicateExisting() {
val existing = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119)
val existing = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(existing))
AuthManager.setSelectedProfileId("prof-1")
@@ -39,8 +39,9 @@ class ApiClientTest {
// can build clients without app context.
CookieManager.setJarForTest(buildFakePersistentCookieJar())
// Point AuthManager at our MockWebServer
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString()
// Point AuthManager at our MockWebServer (the interceptor reads the
// canonical base URL via endpointForBuild() → getBaseUrl()).
every { AuthManager.getBaseUrl() } returns mockWebServer.url("/").toString()
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
@@ -24,6 +24,10 @@ class CookieManagerTest {
return jar
}
private fun ep(host: String): ServerEndpoint {
return ServerEndpoint.fromLegacy(host, 9119)
}
@After
fun tearDown() {
CookieManager.resetForTest()
@@ -32,7 +36,7 @@ class CookieManagerTest {
@Test
fun setThenGetSessionCookie_roundTrips() {
fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie())
}
@@ -40,10 +44,10 @@ class CookieManagerTest {
@Test
fun setSessionCookie_nullClearsValue() {
fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie())
CookieManager.setSessionCookie(null, "dashboard.local")
CookieManager.setSessionCookie(null, ep("dashboard.local"))
assertNull(CookieManager.getSessionCookie())
}
@@ -51,7 +55,7 @@ class CookieManagerTest {
fun setSessionCookie_hostScoped_onlyMatchesThatHost() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
val onHost =
jar.loadForRequest(
@@ -68,7 +72,7 @@ class CookieManagerTest {
fun pruneServerCache_delegatesToJar() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("keep-me", "dashboard.local")
CookieManager.setSessionCookie("keep-me", ep("dashboard.local"))
CookieManager.pruneServerCache()
// Session cookie is preserved by prune.
assertEquals("keep-me", CookieManager.getSessionCookie())
@@ -78,7 +82,7 @@ class CookieManagerTest {
fun clearAll_wipesSession() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("bye", "dashboard.local")
CookieManager.setSessionCookie("bye", ep("dashboard.local"))
CookieManager.clearAll()
assertNull(CookieManager.getSessionCookie())
}
@@ -0,0 +1,149 @@
package com.m57.hermescontrol.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class ServerEndpointTest {
@Test
fun `parse accepts https with trailing slash normalization`() {
val ep = ServerEndpoint.parse("https://example.com/hermes", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("https://example.com/hermes/", ep.baseUrl.toString())
assertNull(ep.securityWarning)
assertFalse(ep.isCleartext)
}
@Test
fun `parse accepts http with warning`() {
val ep = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("http://127.0.0.1:9119/", ep.baseUrl.toString())
assertEquals(ServerEndpoint.CLEARTEXT_WARNING, ep.securityWarning)
assertTrue(ep.isCleartext)
}
@Test
fun `parse rejects non http scheme`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("ftp://example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects embedded credentials`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://user:pass@example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects query string`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://example.com/?token=abc", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects fragment`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://example.com/#frag", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `DENY policy rejects http`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.DENY)
}
}
@Test
fun `DENY policy accepts https`() {
val ep = ServerEndpoint.parse("https://example.com/", CleartextPolicy.DENY)
assertNull(ep.securityWarning)
}
@Test
fun `resolve builds prefixed url and rejects path escape`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("https://example.com/hermes/api/status", ep.resolve("api/status").toString())
assertThrows<IllegalArgumentException> {
ep.resolve("../etc/passwd")
}
assertThrows<IllegalArgumentException> {
ep.resolve(" ")
}
}
@Test
fun `webSocketUrl converts https to wss and http to ws`() {
val https = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals(
"wss://example.com/hermes/api/ws?token=abc",
https.webSocketUrl("token", "abc"),
)
val http = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals(
"ws://127.0.0.1:9119/api/ws?ticket=xyz",
http.webSocketUrl("ticket", "xyz"),
)
}
@Test
fun `webSocketUrl rejects unsupported auth param`() {
val ep = ServerEndpoint.parse("https://example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
assertThrows<IllegalArgumentException> {
ep.webSocketUrl("cookie", "abc")
}
}
@Test
fun `relativeRequestPath strips proxy prefix`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
val request = ep.resolve("api/status")
assertEquals("/api/status", ep.relativeRequestPath(request))
}
@Test
fun `relativeRequestPath passes through non matching origin`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
val other = ServerEndpoint.parse("https://other.com/x/", CleartextPolicy.ALLOW_WITH_WARNING)
val request = other.resolve("api/status")
assertEquals("/x/api/status", ep.relativeRequestPath(request))
}
@Test
fun `redactWebSocketUrlForLog strips query`() {
assertEquals(
"wss://example.com/hermes/api/ws",
ServerEndpoint.redactWebSocketUrlForLog("wss://example.com/hermes/api/ws?token=secret"),
)
assertEquals(
"ws://127.0.0.1:9119/api/ws",
ServerEndpoint.redactWebSocketUrlForLog("ws://127.0.0.1:9119/api/ws?ticket=secret"),
)
}
@Test
fun `fromLegacy preserves http host and port`() {
val ep = ServerEndpoint.fromLegacy("10.0.0.1", 8080)
assertEquals("http://10.0.0.1:8080/", ep.baseUrl.toString())
assertTrue(ep.isCleartext)
}
@Test
fun `DEFAULT_BASE_URL is https localhost 9119`() {
assertEquals("https://127.0.0.1:9119/", ServerEndpoint.DEFAULT_BASE_URL)
}
private inline fun <reified T : Throwable> assertThrows(block: () -> Unit) {
try {
block()
fail("Expected ${T::class.simpleName} but nothing was thrown")
} catch (e: Throwable) {
if (e !is T) throw e
}
}
}
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.authlogin
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Regression test for the basic-auth token-derivation bug.
*
* Root cause (issue #634 rework): AuthLoginViewModel.connectBasicAuth() built a
* FRESH OkHttpClient for the /auth/password-login call, so the Set-Cookie from
* that response was captured by an ephemeral jar and never reached the
* /api/auth/ws-ticket call on a DIFFERENT client. The ticket request then got
* 401, connectBasicAuth returned null, and the UI fell back to asking the user
* to paste a token manually.
*
* Fix: both calls use the SHARED OkHttpProvider.probe client (same
* CookieManager.cookieJar), so the session cookie set by password-login flows
* into the ws-ticket request automatically.
*
* This test proves the contract at the layer that was actually broken: a
* password-login with Set-Cookie on the shared client must let a subsequent
* ws-ticket request on the SAME client carry the cookie and succeed.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AuthLoginBasicAuthFlowTest {
private lateinit var server: MockWebServer
private val dispatcher = StandardTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
server = MockWebServer()
server.start()
CookieManager.setJarForTest(buildFakePersistentCookieJar())
}
@AfterEach
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
@Test
fun `password-login Set-Cookie flows into ws-ticket on the shared client`() =
runTest {
// password-login: returns JSON (no token in body) + session Set-Cookie
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setHeader("Set-Cookie", "hermes_access_token=abc123; Path=/; HttpOnly")
.setBody("{\"ok\":true,\"next\":\"/\"}"),
)
// ws-ticket: mints only if the session cookie is present on the request
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{\"ticket\":\"ws-ticket-xyz\",\"ttl_seconds\":30}"),
)
val base = server.url("/").toString().removeSuffix("/")
// Step 1: login on the SHARED client (what the fixed code does)
val loginReq =
Request
.Builder()
.url("$base/auth/password-login")
.header("Content-Type", "application/json")
.post("{\"username\":\"admin\",\"password\":\"s3cret\"}".toRequestBody())
.build()
val loginResp = OkHttpProvider.probe.newCall(loginReq).execute()
assertEquals(200, loginResp.code)
// Step 2: ws-ticket on the SAME shared client — cookie must carry over
val ticketReq =
Request
.Builder()
.url("$base/api/auth/ws-ticket")
.post("{}".toRequestBody())
.build()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
assertEquals(200, ticketResp.code, "ws-ticket must succeed with the session cookie from login")
val body = ticketResp.body.string()
assertTrue(body.contains("\"ticket\""), "ws-ticket response should contain a ticket")
}
@Test
fun `ws-ticket without prior login cookie fails (proves cookie is the gate)`() =
runTest {
// No login queued; ws-ticket should be rejected (401) because no session
server.enqueue(MockResponse().setResponseCode(401).setBody("Unauthorized"))
val base = server.url("/").toString().removeSuffix("/")
val ticketReq =
Request
.Builder()
.url("$base/api/auth/ws-ticket")
.post("{}".toRequestBody())
.build()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
assertEquals(401, ticketResp.code)
}
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -29,8 +30,7 @@ class AuthLoginViewModelTest {
@Before
fun setup() {
mockkObject(AuthManager)
every { AuthManager.getHost() } returns "localhost"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getConnectionProfiles() } returns emptyList()
mockkObject(OkHttpProvider)
@@ -51,21 +51,22 @@ class AuthLoginViewModelTest {
}
@Test
fun `onHostChange updates host and clears error and auth mode`() {
viewModel.onHostChange(" 192.168.1.100 ")
fun `onBaseUrlChange updates baseUrl and clears error and auth mode`() {
viewModel.onBaseUrlChange(" https://192.168.1.100:9119/ ")
val state = viewModel.uiState.value
assertEquals("192.168.1.100", state.host)
assertEquals("https://192.168.1.100:9119/", state.baseUrl)
assertNull(state.errorMessage)
assertNull(state.authMode)
}
@Test
fun `onPortChange updates port keeping only digits and clears error and auth mode`() {
viewModel.onPortChange("9a1b1c9")
fun `onBaseUrlChange with http sets transport warning`() {
viewModel.onBaseUrlChange("http://127.0.0.1:9119/")
val state = viewModel.uiState.value
assertEquals("9119", state.port)
assertEquals("http://127.0.0.1:9119/", state.baseUrl)
assertEquals(ServerEndpoint.CLEARTEXT_WARNING, state.transportWarning)
assertNull(state.errorMessage)
assertNull(state.authMode)
}
@@ -109,4 +110,46 @@ class AuthLoginViewModelTest {
assertNull(state.authMode)
assertEquals("Dashboard unreachable", state.errorMessage)
}
// ── deriveAuthMode (authoritative /api/status mapping) ──
@Test
fun `deriveAuthMode gate down returns ALL sentinel`() {
assertEquals(
DashboardAuthMode.ALL,
viewModel.deriveAuthMode(authRequired = false, providers = emptyList()),
)
}
@Test
fun `deriveAuthMode oauth provider returns OAUTH`() {
assertEquals(
DashboardAuthMode.OAUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("oauth")),
)
}
@Test
fun `deriveAuthMode basic provider returns BASIC_AUTH`() {
assertEquals(
DashboardAuthMode.BASIC_AUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("basic")),
)
}
@Test
fun `deriveAuthMode oauth takes precedence over basic`() {
assertEquals(
DashboardAuthMode.OAUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("basic", "oauth")),
)
}
@Test
fun `deriveAuthMode gate up with unknown provider falls back to BASIC_AUTH`() {
assertEquals(
DashboardAuthMode.BASIC_AUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("weird")),
)
}
}
@@ -6,7 +6,9 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.ServerEndpoint
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -49,27 +51,28 @@ class ConnectViewModelTest {
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any(), any()) } returns mockApiService
every { ApiClient.createTempService(any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every {
AuthManager.endpoint()
} answers { ServerEndpoint.parse("https://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING) }
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.ensureDefaultSelected() } returns Unit
// Mock Application string resources
every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required"
every { mockApp.getString(R.string.connect_error_host_required) } returns "Host is required"
every { mockApp.getString(R.string.connect_error_port_invalid) } returns "Port must be between 1 and 65535"
every { mockApp.getString(R.string.connect_error_url_invalid) } returns "URL is invalid"
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
every { mockApp.getString(R.string.connect_error_403) } returns "Access denied (403 Forbidden)"
every { mockApp.getString(R.string.connect_error_http_code) } returns "Server returned HTTP %1\$d"
@@ -94,15 +97,13 @@ class ConnectViewModelTest {
@Test
fun testInitialLoadingFromAuthManager() {
every { AuthManager.getToken() } returns "saved-token"
every { AuthManager.getHost() } returns "hermes.local"
every { AuthManager.getPort() } returns 8888
every { AuthManager.getBaseUrl() } returns "http://hermes.local:8888/"
val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value
assertEquals("saved-token", state.token)
assertEquals("hermes.local", state.host)
assertEquals("8888", state.port)
assertEquals("http://hermes.local:8888/", state.baseUrl)
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertNull(state.errorMessage)
@@ -120,7 +121,7 @@ class ConnectViewModelTest {
fun testOnHostChange() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onHostChange(" 192.168.1.50 ")
assertEquals("192.168.1.50", viewModel.uiState.value.host)
assertEquals("https://192.168.1.50:9119/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage)
}
@@ -128,7 +129,7 @@ class ConnectViewModelTest {
fun testOnPortChange() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onPortChange("90abc90")
assertEquals("9090", viewModel.uiState.value.port)
assertEquals("https://127.0.0.1:9090/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage)
}
@@ -141,31 +142,17 @@ class ConnectViewModelTest {
}
@Test
fun testConnect_blankHost_showsError() {
fun testConnect_invalidUrl_showsError() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("")
viewModel.connect()
assertEquals("Host is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_invalidPortBounds_showsError() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("0")
viewModel.onBaseUrlChange("https://127.0.0.1:65536/")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
assertEquals("URL is invalid", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("65536")
viewModel.onBaseUrlChange("not-a-url")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
assertEquals("URL is invalid", viewModel.uiState.value.errorMessage)
}
@Test
@@ -173,8 +160,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
@@ -195,8 +181,7 @@ class ConnectViewModelTest {
advanceUntilIdle()
verify { AuthManager.setToken("valid-token") }
verify { AuthManager.setHost("127.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { AuthManager.setBaseUrl("https://127.0.0.1:9119/") }
verify { ApiClient.rebuild() }
val state = viewModel.uiState.value
@@ -210,8 +195,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -236,8 +220,7 @@ class ConnectViewModelTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -261,8 +244,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -283,8 +265,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -305,8 +286,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("Connection refused")
@@ -324,8 +304,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("timeout exception")
@@ -343,8 +322,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("Something went wrong")
@@ -357,43 +335,13 @@ class ConnectViewModelTest {
assertEquals("Connection failed: Something went wrong", state.errorMessage)
}
@Test
fun testOnPairingString_malformedBase64_showsError() {
val viewModel = ConnectViewModel(mockApp)
// Mock Base64.decode to throw IllegalArgumentException for an invalid short string
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
viewModel.onPairingString("short-invalid-string")
val state = viewModel.uiState.value
assertEquals("Malformed pairing string — expected URL or Base64-encoded JSON", state.errorMessage)
}
@Test
fun testOnPairingString_malformedBase64_validRawToken() {
val viewModel = ConnectViewModel(mockApp)
// Mock Base64.decode to throw IllegalArgumentException
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
// A string that is >= 32 chars and alphanumeric matching the raw token regex
val validToken = "dummy_token_value_that_is_long_enough_to_pass_validation_123"
viewModel.onPairingString(validToken)
val state = viewModel.uiState.value
assertEquals(validToken, state.token)
assertNull(state.errorMessage)
}
@Test
fun testSelectProfile_updatesState() {
val profile =
ConnectionProfile(
id = "test-id",
name = "Test Profile",
host = "192.168.1.100",
port = 8080,
baseUrl = "https://192.168.1.100:8080/",
)
every { AuthManager.getProfileToken("test-id") } returns "profile-token"
@@ -402,8 +350,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value
assertEquals("Test Profile", state.profileName)
assertEquals("192.168.1.100", state.host)
assertEquals("8080", state.port)
assertEquals("https://192.168.1.100:8080/", state.baseUrl)
assertEquals("profile-token", state.token)
assertEquals(profile, state.selectedProfile)
@@ -415,8 +362,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onProfileNameChange("New Profile")
viewModel.onSaveProfileChange(true)
@@ -444,7 +390,7 @@ class ConnectViewModelTest {
assertTrue(state.connectionSuccess)
}
// ── TEST-06: Profile edge cases ─────────────────────────────────────
// ── Profile edge cases ─────────────────────────────────────────────
@Test
fun testLoadSavedValues_withProfiles_loadsSelected() {
@@ -452,12 +398,10 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-1",
name = "Work",
host = "10.0.0.1",
port = 9119,
baseUrl = "https://10.0.0.1:9119/",
)
every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "prof-1"
@@ -465,8 +409,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value
assertEquals("Work", state.profileName)
assertEquals("10.0.0.1", state.host)
assertEquals("9119", state.port)
assertEquals("https://10.0.0.1:9119/", state.baseUrl)
assertEquals(profile, state.selectedProfile)
assertEquals(1, state.profiles.size)
}
@@ -477,18 +420,18 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-1",
name = "Work",
host = "10.0.0.1",
port = 9119,
baseUrl = "https://10.0.0.1:9119/",
)
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "nonexistent-id"
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value
assertNull(state.selectedProfile)
assertEquals("", state.profileName)
assertEquals("127.0.0.1", state.host)
assertEquals("https://127.0.0.1:9119/", state.baseUrl)
}
@Test
@@ -499,8 +442,7 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-2",
name = "NoToken",
host = "10.0.0.2",
port = 9220,
baseUrl = "http://10.0.0.2:9220/",
)
val viewModel = ConnectViewModel(mockApp)
viewModel.selectProfile(profile)
@@ -514,8 +456,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("standalone-token")
viewModel.onHostChange("10.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("http://10.0.0.1:9119/")
viewModel.onSaveProfileChange(false)
val mockResponse = mockk<Response<StatusResponse>>()
@@ -534,8 +475,7 @@ class ConnectViewModelTest {
advanceUntilIdle()
verify { AuthManager.setToken("standalone-token") }
verify { AuthManager.setHost("10.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { AuthManager.setBaseUrl("http://10.0.0.1:9119/") }
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
}
@@ -560,8 +500,8 @@ class ConnectViewModelTest {
fun testMultipleProfiles_loadedOnInit() {
val profiles =
listOf(
ConnectionProfile("a", "Alpha", "10.0.0.1", 9119),
ConnectionProfile("b", "Beta", "10.0.0.2", 9220),
ConnectionProfile("a", "Alpha", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile("b", "Beta", baseUrl = "http://10.0.0.2:9220/"),
)
every { AuthManager.getConnectionProfiles() } returns profiles
every { AuthManager.getSelectedProfileId() } returns null
@@ -579,40 +519,4 @@ class ConnectViewModelTest {
.name,
)
}
@Test
fun testOnPairingString_hermesUrl_parsesAndConnects() =
runTest {
// Stub the Uri mock for this test
every { android.net.Uri.parse(any()) } answers {
val uri = mockk<android.net.Uri>(relaxed = true)
every { uri.getQueryParameter("host") } returns "192.168.1.1"
every { uri.getQueryParameter("port") } returns "8888"
every { uri.getQueryParameter("token") } returns "abc123"
uri
}
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
every { mockResponse.body() } returns
StatusResponse(
version = "1.0",
gateway_running = true,
active_sessions = 0,
auth_required = false,
gateway_platforms = emptyMap(),
)
coEvery { mockApiService.getStatus() } returns mockResponse
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
val state = viewModel.uiState.value
assertEquals("192.168.1.1", state.host)
assertEquals("8888", state.port)
assertEquals("abc123", state.token)
// Should have triggered connect
advanceUntilIdle()
assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess)
}
}
@@ -34,8 +34,8 @@ class SettingsViewModelTest {
private val testProfiles =
listOf(
ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119),
ConnectionProfile("prof-2", "Home", "10.0.0.2", 9220),
ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile(id = "prof-2", name = "Home", baseUrl = "http://10.0.0.2:9220/"),
)
private fun createViewModel(): SettingsViewModel {
@@ -56,6 +56,8 @@ class SettingsViewModelTest {
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getAppLanguage() } returns "system"
every { AuthManager.getToken() } returns ""
every { AuthManager.isAutoReconnect() } returns true
every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM
@@ -70,6 +72,7 @@ class SettingsViewModelTest {
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setAutoReconnect(any()) } returns Unit
every { AuthManager.setThemePreference(any()) } returns Unit
@@ -80,10 +83,11 @@ class SettingsViewModelTest {
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers {
storedSelectedProfileId = firstArg()
Unit
}
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.ensureDefaultProfile() } returns Unit
// Ensure ApiClient.rebuild() is stubbed (used by selectProfile)
every { ApiClient.rebuild() } returns Unit
@@ -225,8 +229,7 @@ class SettingsViewModelTest {
viewModel.openAddProfile()
viewModel.onDialogProfileNameChange("Test Profile")
viewModel.onDialogProfileHostChange("192.168.1.100")
viewModel.onDialogProfilePortChange("9999")
viewModel.onDialogProfileBaseUrlChange("https://192.168.1.100:9999/")
viewModel.saveProfileFromDialog()
testDispatcher.scheduler.advanceUntilIdle()