* [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:
1 parent
d839c5e9ad
commit
26b104b2bb
40 files changed
+1299
-688
No files matched your search
+15
-2
@@ -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/**'
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+17
-16
@@ -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))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user