diff --git a/.gitguardian.yml b/.gitguardian.yml index 78f53bc4..42893d69 100644 --- a/.gitguardian.yml +++ b/.gitguardian.yml @@ -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/**' diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a13bd862..d96ed14d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d408c5d..976daf96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> { 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 { diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt index bfa2b28a..a5a3d055 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index d3383282..6bfadc8d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt new file mode 100644 index 00000000..02c926a7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerUrlMigration.kt @@ -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 { + 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() + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index b21d67eb..55679a2e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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 ────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index fc6ed5e4..79f21598 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/AuthPayloads.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/AuthPayloads.kt new file mode 100644 index 00000000..9fafab38 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/AuthPayloads.kt @@ -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(body) + .ticket + .takeIf { it.isNotBlank() } + }.getOrNull() +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt index 9e9ff63a..db11b010 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt @@ -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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt new file mode 100644 index 00000000..c01e9806 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt @@ -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 "" + } + val socketScheme = if (httpScheme == "https") "wss" else "ws" + val converted = + replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull() + ?: return "" + val redacted = converted.newBuilder().query(null).build().toString() + return replaceScheme(redacted, socketScheme) + } + + private fun replaceScheme( + url: String, + scheme: String, + ): String = url.replaceBefore("://", scheme) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index ebf55c74..c6d28865 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt index fdc0368f..d4c119f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt index af502dd1..54a049a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -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 = _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, + ): 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 + 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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a8232e6d..d780acec 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index 1e45a6e6..de76f801 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -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(), - ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index e5b8c1ac..6e618651 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt index a30a882e..146a6a34 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingCodeEntryViewModel.kt @@ -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() } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 436ffc37..b94a9fbf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -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() } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index b95cdf28..022ee105 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 97771511..e7a74dc1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 7925b0e3..0e1ce248 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index ec4928dd..e9417d77 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt index 92ea1767..a99f5b30 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt index 14979241..64137ab9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt @@ -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)) } }, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 17f921c7..55015110 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -224,7 +224,6 @@ 잘못된 페어링 문자열 — URL 또는 Base64 JSON이어야 함 페어링 문자열 파싱 실패: %1$s - 페어링 문자열 hermes://… 또는 Base64 설정 붙여넣기 연결 프로필 저장 프로필 이름 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc99cfd9..543d7a88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -254,8 +254,7 @@ Malformed pairing string — expected URL or Base64-encoded JSON Failed to parse pairing string: %1$s - Pairing string - Paste hermes://\u2026 or Base64 config + Paste hermes://… or Base64 config Save connection profile Profile Name e.g. Local Hermit, Remote Server @@ -264,6 +263,9 @@ API token 127.0.0.1 9119 + Server URL + https://host:9119/ or http://127.0.0.1:9119/ + Enter a valid server URL (https:// or http://) Connect @@ -807,6 +809,7 @@ Auth Login Connect to your Hermes dashboard + Server URL Host Port Session Token @@ -821,6 +824,8 @@ Username is required Password is required Dashboard unreachable — check IP and port + OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639) + This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile. Use existing logged-in profile: Enter as %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 69c3e790..08d9a76f 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -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() - 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() - 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() - 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) - } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerUrlMigrationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerUrlMigrationTest.kt new file mode 100644 index 00000000..554efacd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerUrlMigrationTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 4d119e65..cf829dbf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -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") diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt index deac9e7a..0f84728e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt index 814969c8..a3334c25 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt @@ -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()) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt new file mode 100644 index 00000000..dc88c434 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt @@ -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 { + ServerEndpoint.parse("ftp://example.com/", CleartextPolicy.ALLOW_WITH_WARNING) + } + } + + @Test + fun `parse rejects embedded credentials`() { + assertThrows { + ServerEndpoint.parse("https://user:pass@example.com/", CleartextPolicy.ALLOW_WITH_WARNING) + } + } + + @Test + fun `parse rejects query string`() { + assertThrows { + ServerEndpoint.parse("https://example.com/?token=abc", CleartextPolicy.ALLOW_WITH_WARNING) + } + } + + @Test + fun `parse rejects fragment`() { + assertThrows { + ServerEndpoint.parse("https://example.com/#frag", CleartextPolicy.ALLOW_WITH_WARNING) + } + } + + @Test + fun `DENY policy rejects http`() { + assertThrows { + 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 { + ep.resolve("../etc/passwd") + } + assertThrows { + 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 { + 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 assertThrows(block: () -> Unit) { + try { + block() + fail("Expected ${T::class.simpleName} but nothing was thrown") + } catch (e: Throwable) { + if (e !is T) throw e + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginBasicAuthFlowTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginBasicAuthFlowTest.kt new file mode 100644 index 00000000..ccfb7833 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginBasicAuthFlowTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt index e60d4d51..69f34d0c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt @@ -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")), + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 14cd4d20..df1ecdd9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -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>() 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>() 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>() 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>() 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>() 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(), 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(), 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>() @@ -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(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>() - 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) - } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 3e4fe8e5..cae5fc6b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -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()