* feat(#349): smart auth login flow — probe dashboard, show correct fields Landing screen with two buttons (Auth Login / Pairing Login) replaces ConnectScreen as the start screen. Auth login probes the dashboard to detect what auth it needs (token-only, basic-auth, or both) and shows only the relevant input fields. Changes: - NavigationKeys.kt: add LandingScreen, AuthLoginScreen NavKeys - LandingScreen.kt: new screen with hero icon + two login buttons - AuthLoginScreen.kt: IP/Port input, probe logic, dynamic field rendering - AuthLoginViewModel.kt: probe API, dashboard auth detection, connect flow - Navigation.kt: wire new screens, start on LandingScreen, hide bottom bar - strings.xml: add string resources for both screens * fix(#351): align AuthLoginScreen field names with ViewModel API, fix TAG refs - state.isProbing → state.probing - DashboardAuthMode.BOTH → DashboardAuthMode.ALL - viewModel.probe() → viewModel.probeDashboard() - state.isConnecting → state.isLoading - Removed unused TAG constant, replaced with inline tag string - Made connectTokenOnly suspend to fix safeApiCall compilation This fixes the CI compilation errors. * fix(#351): revert probeDashboard() call to probe() to match VM * fix(#351): rename isProbing to probing in ViewModel * fix(#351): follow redirects in basic auth login — / redirects to /login * fix(#351): show all fields when basic auth detected — still needs session token for WS * refactor(#349): remove old ConnectScreen, clean up NavKey + navigation * fix(#351): validate token against /api/sessions (auth-protected) instead of /api/status (public) /api/status is a public endpoint that returns 200 even with wrong tokens, causing invalid tokens to be saved and the WebSocket to spin forever on the chat screen. /api/sessions requires auth and returns 401 on bad tokens. * fix(#351): rewrite basic auth login — POST to /auth/password-login, then fetch SPA with cookie to extract token * fix(#351): use ticket-based WS auth when basic auth is active The updated dashboard on 0.0.0.0 always has auth_required=True, which rejects legacy ?token= in favor of ?ticket= (single-use, 30s TTL). Added AuthManager.setWsAuthParam() to support both ?token= (loopback) and ?ticket= (gated mode) in wsUrl(). connectBasicAuth now mints a ticket via POST /api/auth/ws-ticket after login instead of extracting the token from the SPA. * fix(#351): use requirePrefs() instead of direct prefs reference in wsUrl() * fix(#351): handle relaxed-mock empty string in wsUrl() authParam fallback * feat(#349): auto-extract session token from SPA HTML during probe The probe now extracts the __HERMES_SESSION_TOKEN__ value from the dashboard SPA and auto-populates the token field in TOKEN_ONLY mode, eliminating the need for manual token pasting. - Introduced ProbeResult data class to carry authMode + extractedToken - Regex extraction of __HERMES_SESSION_TOKEN__ from dashboard HTML - Token field auto-filled on probe completion for TOKEN_ONLY mode * fix(#349): return BASIC_AUTH from probe when basic auth detected Bug: probe returned ALL instead of BASIC_AUTH for basic-auth-only dashboards, causing token+credential fields to show and the connect flow to fall through to connectTokenOnly() on basic auth failure, producing a confusing 'Unauthorized (HTTP 401)' NetworkResult error. Fix: probe now returns DashboardAuthMode.BASIC_AUTH when a redirect to /login is detected. BASIC_AUTH in connect() calls connectBasicAuth() directly with no fallback. ALL mode also no longer falls through to connectTokenOnly(). * fix(#349): use session cookie for REST API in gated mode The dashboard's gated mode authenticates REST API requests via session cookies (hermes_session_at), not Bearer tokens. The app was storing the WS ticket as the token and sending it as Authorization: Bearer <ticket> for all REST calls, which the server rejected with 401. - Extract hermes_session_at cookie from Set-Cookie header after POST /auth/password-login and store it in AuthManager - ApiClient authInterceptor now sends Cookie header when session cookie is present, falls back to Bearer token for loopback mode - ConnectResult carries both WS credential (ticket) and session cookie separately - WS ticket used only for WebSocket (?ticket=), session cookie used for REST API * feat(#349): add logout button to Settings > About Red error-style button that clears all auth credentials (token, session cookie, WS auth param) and navigates back to LandingScreen. Wired through SettingsScreen onLogout callback to Navigation.kt. * fix(#349): improve logout navigation flow * fix(#349): keep backstack stable across logout Use remember without keys for NavBackStack so the same backstack object persists across startScreen changes. Use LaunchedEffect keyed on startScreen to reset stack content atomically. This avoids NavDisplay losing snapshot observation when a new NavBackStack object is created. * fix(#349): mint fresh WS ticket on every connect The WS ticket is a single-use 30-second credential. On app restart (or reconnect), the stored ticket is expired. Now refreshWsTicketIfNeeded() mints a new ticket using the session cookie before every WS connection attempt. Loopback mode (no session cookie) is unaffected. * fix(#349): refresh ticket in openSocket for reconnect paths scheduleReconnect() bypasses connect() and calls openSocket() directly. Moving the ticket refresh into openSocket() ensures every WS connection attempt — first launch and reconnect — uses a fresh ticket. * fix(#349): stub getSessionCookie() in ApiClientTest mockkObject(AuthManager) is strict, so unstubbed getSessionCookie() calls throw MockKException which manifests as IOException. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
11ca9b55c8
commit
c1af989220
13 files changed
+1026
-362
No files matched your search
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -64,9 +63,9 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
@@ -74,14 +73,15 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.BottomNavDisplayMode
|
||||
import kotlinx.coroutines.launch
|
||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
|
||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
|
||||
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||
import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent
|
||||
import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
|
||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||
import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
||||
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
||||
@@ -193,12 +193,25 @@ private fun appEntryProvider(
|
||||
sessionId: String?,
|
||||
openDrawer: () -> Unit,
|
||||
) = entryProvider {
|
||||
entry<ConnectScreen> {
|
||||
ConnectScreenContent(
|
||||
entry<LandingScreen> {
|
||||
LandingScreenContent(
|
||||
onAuthLogin = {
|
||||
NavigationController.backStack?.add(AuthLoginScreen)
|
||||
},
|
||||
onPairingLogin = {
|
||||
NavigationController.backStack?.add(PairingScreen)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<AuthLoginScreen> {
|
||||
AuthLoginScreenContent(
|
||||
onConnected = {
|
||||
NavigationController.resetTo(ChatScreen)
|
||||
},
|
||||
modifier = Modifier.safeDrawingPadding(),
|
||||
onBack = {
|
||||
NavigationController.goBack()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,6 +231,14 @@ private fun appEntryProvider(
|
||||
entry<SettingsScreen> {
|
||||
SettingsScreenContent(
|
||||
onBack = { NavigationController.goBack() },
|
||||
onLogout = {
|
||||
// First clear auth state to trigger token flow
|
||||
// then reset navigation to landing screen
|
||||
NavigationController.backStack?.let { stack ->
|
||||
stack.clear()
|
||||
stack.add(LandingScreen)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -328,9 +349,17 @@ private fun appEntryProvider(
|
||||
fun MainNavigation(sessionId: String? = null) {
|
||||
val token by AuthManager.tokenFlow.collectAsState()
|
||||
val hasToken = !token.isNullOrBlank()
|
||||
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
|
||||
val startScreen: NavKey = if (hasToken) ChatScreen else LandingScreen
|
||||
|
||||
val backStack = rememberNavBackStack(startScreen)
|
||||
val backStack = remember { NavBackStack(startScreen) }
|
||||
LaunchedEffect(startScreen) {
|
||||
// When start screen changes (e.g., logout → LandingScreen, or
|
||||
// fresh login → ChatScreen), reset the backstack atomically.
|
||||
if (backStack.lastOrNull() != startScreen) {
|
||||
backStack.clear()
|
||||
backStack.add(startScreen)
|
||||
}
|
||||
}
|
||||
NavigationController.backStack = backStack
|
||||
|
||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||
@@ -353,7 +382,9 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
NavigationController.updatePrimaryScreens(bottomNavKeys)
|
||||
}
|
||||
|
||||
val showBottomBar = currentScreen != ConnectScreen
|
||||
val showBottomBar =
|
||||
currentScreen != LandingScreen &&
|
||||
currentScreen != AuthLoginScreen
|
||||
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
|
||||
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.m57.hermescontrol
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object ConnectScreen : NavKey
|
||||
@Serializable data object LandingScreen : NavKey
|
||||
|
||||
@Serializable data object AuthLoginScreen : NavKey
|
||||
|
||||
@Serializable data object ChatScreen : NavKey
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ object AuthManager {
|
||||
private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms"
|
||||
private const val KEY_CONNECTION_PROFILES = "connection_profiles"
|
||||
private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id"
|
||||
private const val KEY_WS_AUTH_PARAM = "ws_auth_param"
|
||||
private const val KEY_SESSION_COOKIE = "session_cookie"
|
||||
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
|
||||
private const val KEY_THEME_PRESET = "theme_preset"
|
||||
private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode"
|
||||
@@ -93,9 +95,25 @@ object AuthManager {
|
||||
}
|
||||
|
||||
private fun requirePrefs(): SharedPreferences =
|
||||
prefs ?: throw IllegalStateException(
|
||||
"AuthManager not initialised – call AuthManager.init(context) first",
|
||||
)
|
||||
prefs ?: throw IllegalStateException("AuthManager not initialized. Call init(context) first.")
|
||||
|
||||
fun setWsAuthParam(param: String) {
|
||||
requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply()
|
||||
}
|
||||
|
||||
// ── Session Cookie (for gated/dashboard REST API) ────────────────────
|
||||
|
||||
/**
|
||||
* In gated mode (basic auth), the dashboard authenticates REST API
|
||||
* requests via the `hermes_session_at` cookie, not via
|
||||
* `Authorization: *** We store it here so [ApiClient]'s
|
||||
* authInterceptor can add it as a `Cookie` header.
|
||||
*/
|
||||
fun getSessionCookie(): String? = requirePrefs().getString(KEY_SESSION_COOKIE, null)
|
||||
|
||||
fun setSessionCookie(cookie: String?) {
|
||||
requirePrefs().edit().putString(KEY_SESSION_COOKIE, cookie).apply()
|
||||
}
|
||||
|
||||
// ── Connection Profiles ──────────────────────────────────────────────
|
||||
|
||||
@@ -290,7 +308,12 @@ object AuthManager {
|
||||
|
||||
/** Convenience: build the WebSocket URL with token query param.
|
||||
* NOTE: Token in query string — trusted local network only. */
|
||||
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
|
||||
fun wsUrl(): String {
|
||||
val raw = requirePrefs().getString(KEY_WS_AUTH_PARAM, "token")
|
||||
val authParam = if (raw.isNullOrBlank()) "token" else raw
|
||||
val credential = getToken().orEmpty()
|
||||
return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential"
|
||||
}
|
||||
|
||||
// ── Bottom nav bar items ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -100,18 +100,30 @@ object ApiClient {
|
||||
|
||||
val authInterceptor =
|
||||
Interceptor { chain ->
|
||||
val token = AuthManager.getToken()
|
||||
val request =
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain
|
||||
.request()
|
||||
val request = chain.request()
|
||||
val sessionCookie = AuthManager.getSessionCookie()
|
||||
if (!sessionCookie.isNullOrBlank()) {
|
||||
// Gated mode: authenticate via session cookie
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
.addHeader("Cookie", "hermes_session_at=$sessionCookie")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
// Loopback mode: authenticate via Bearer token
|
||||
val token = AuthManager.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
chain.request()
|
||||
chain.proceed(request)
|
||||
}
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
// SEC-11: No certificate pinning is configured by default since the app
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
|
||||
import okhttp3.CertificatePinner
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
@@ -139,9 +140,51 @@ object HermesWsClient {
|
||||
intentionalClose.set(false)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||
refreshWsTicketIfNeeded()
|
||||
openSocket()
|
||||
}
|
||||
|
||||
/**
|
||||
* If a session cookie is present (gated mode), mint a fresh WS ticket
|
||||
* from the dashboard. The ticket is single-use and has a 30-second TTL,
|
||||
* so we must mint a new one on every connect (first launch and reconnect).
|
||||
*/
|
||||
private fun refreshWsTicketIfNeeded() {
|
||||
val sessionCookie = AuthManager.getSessionCookie()
|
||||
if (sessionCookie.isNullOrBlank()) {
|
||||
// Loopback mode — the stored token IS a session token, not a
|
||||
// WS ticket, so no refresh needed.
|
||||
return
|
||||
}
|
||||
try {
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
|
||||
.header("Cookie", "hermes_session_at=$sessionCookie")
|
||||
.post(RequestBody.create(null, "{}"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string() ?: ""
|
||||
val ticketMatch = Regex(""""ticket":"([^"]+)"""").find(body)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
AuthManager.setToken(ticket)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect() {
|
||||
intentionalClose.set(true)
|
||||
@@ -197,6 +240,7 @@ object HermesWsClient {
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun openSocket() {
|
||||
refreshWsTicketIfNeeded()
|
||||
val url = AuthManager.wsUrl()
|
||||
// B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token
|
||||
// as a query param — never stream to logcat in release builds.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.m57.hermescontrol.ui.authlogin
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
@Composable
|
||||
fun AuthLoginScreen(
|
||||
onConnected: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AuthLoginViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
run {
|
||||
val app = LocalContext.current.applicationContext as Application
|
||||
AuthLoginViewModelFactory(app)
|
||||
},
|
||||
),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) {
|
||||
onConnected()
|
||||
}
|
||||
}
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Hero icon
|
||||
Box(
|
||||
modifier = Modifier.size(64.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bolt,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.auth_login_title),
|
||||
style =
|
||||
MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.auth_login_desc),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Host field
|
||||
OutlinedTextField(
|
||||
value = state.host,
|
||||
onValueChange = viewModel::onHostChange,
|
||||
label = { Text(stringResource(R.string.auth_login_host_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,
|
||||
)
|
||||
|
||||
// Probe / probing indicator
|
||||
if (state.probing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.auth_login_probing_dashboard),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Dynamic fields based on auth mode ──
|
||||
|
||||
// Token field — shown for TOKEN_ONLY and ALL
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
state.authMode == DashboardAuthMode.TOKEN_ONLY ||
|
||||
state.authMode == DashboardAuthMode.ALL,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.token,
|
||||
onValueChange = viewModel::onTokenChange,
|
||||
label = { Text(stringResource(R.string.auth_login_token_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Username field — shown for BASIC_AUTH and ALL
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
state.authMode == DashboardAuthMode.BASIC_AUTH ||
|
||||
state.authMode == DashboardAuthMode.ALL,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text(stringResource(R.string.auth_login_username_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// Password field — shown for BASIC_AUTH and ALL
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
state.authMode == DashboardAuthMode.BASIC_AUTH ||
|
||||
state.authMode == DashboardAuthMode.ALL,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text(stringResource(R.string.auth_login_password_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
AnimatedVisibility(
|
||||
visible = state.errorMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
state.errorMessage?.let { msg ->
|
||||
Text(
|
||||
text = msg,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Connect / Probe button
|
||||
Button(
|
||||
onClick = {
|
||||
if (state.authMode == null) {
|
||||
viewModel.probe()
|
||||
} else {
|
||||
viewModel.connect()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
enabled = !state.isLoading && !state.probing,
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text =
|
||||
if (state.authMode == null) {
|
||||
stringResource(R.string.auth_login_action_probing)
|
||||
} else {
|
||||
stringResource(R.string.auth_login_action_connect)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package com.m57.hermescontrol.ui.authlogin
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* What auth the dashboard requires.
|
||||
*/
|
||||
enum class DashboardAuthMode {
|
||||
/** Dashboard has no auth gate — just needs a session token. */
|
||||
TOKEN_ONLY,
|
||||
|
||||
/** Dashboard has basic auth — needs username + password (and possibly a token). */
|
||||
BASIC_AUTH,
|
||||
|
||||
/** Dashboard requires both basic auth credentials and a session token. */
|
||||
ALL,
|
||||
}
|
||||
|
||||
data class AuthLoginUiState(
|
||||
val host: String = "127.0.0.1",
|
||||
val port: String = "9119",
|
||||
val token: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val probing: Boolean = false,
|
||||
val authMode: DashboardAuthMode? = null,
|
||||
val connectionSuccess: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class AuthLoginViewModel(private val app: Application) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AuthLoginUiState())
|
||||
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthLoginVM"
|
||||
}
|
||||
|
||||
private val probeClient: OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
fun onUsernameChange(value: String) {
|
||||
_uiState.update { it.copy(username = value.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onPasswordChange(value: String) {
|
||||
_uiState.update { it.copy(password = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Probe the dashboard to detect what auth it needs.
|
||||
*/
|
||||
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)) }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(probing = true, errorMessage = null, authMode = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
probeDashboardInternal(state.host, port)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
probing = false,
|
||||
authMode = result?.authMode,
|
||||
token = result?.extractedToken ?: it.token,
|
||||
errorMessage =
|
||||
if (result == null) {
|
||||
app.getString(R.string.auth_login_error_unreachable)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of probing the dashboard.
|
||||
*/
|
||||
private data class ProbeResult(
|
||||
val authMode: DashboardAuthMode,
|
||||
val extractedToken: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun probeDashboardInternal(
|
||||
host: String,
|
||||
port: Int,
|
||||
): ProbeResult? {
|
||||
val baseUrl = "http://$host:$port"
|
||||
|
||||
// Step 1: Check if dashboard is reachable via /api/status (always public)
|
||||
val statusOk =
|
||||
try {
|
||||
val req = Request.Builder().url("$baseUrl/api/status").get().build()
|
||||
val resp = probeClient.newCall(req).execute()
|
||||
resp.isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Status probe failed: ${e.message}")
|
||||
return null // Dashboard unreachable
|
||||
}
|
||||
|
||||
if (!statusOk) return null
|
||||
|
||||
// Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA
|
||||
val needsBasicAuth =
|
||||
try {
|
||||
val req = Request.Builder().url(baseUrl).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 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 authMode =
|
||||
if (needsBasicAuth) {
|
||||
DashboardAuthMode.BASIC_AUTH
|
||||
} else if (extractedToken != null) {
|
||||
DashboardAuthMode.TOKEN_ONLY
|
||||
} else {
|
||||
DashboardAuthMode.ALL
|
||||
}
|
||||
|
||||
return ProbeResult(authMode = authMode, extractedToken = extractedToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful connect attempt.
|
||||
*/
|
||||
private data class ConnectResult(
|
||||
/** WS credential: session token (loopback) or WS ticket (gated). */
|
||||
val wsCredential: String,
|
||||
/** Session cookie for REST API auth in gated mode (null in loopback). */
|
||||
val sessionCookie: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Step 2: Connect using the detected auth mode.
|
||||
*/
|
||||
fun connect() {
|
||||
val state = _uiState.value
|
||||
val port = state.port.toIntOrNull() ?: return
|
||||
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
when (state.authMode) {
|
||||
DashboardAuthMode.TOKEN_ONLY -> {
|
||||
val token = connectTokenOnly(state.host, port, state.token)
|
||||
if (token != null) ConnectResult(wsCredential = token) else null
|
||||
}
|
||||
DashboardAuthMode.BASIC_AUTH ->
|
||||
connectBasicAuth(state.host, port, state.username, state.password)
|
||||
DashboardAuthMode.ALL -> {
|
||||
connectBasicAuth(state.host, port, state.username, state.password)
|
||||
}
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
AuthManager.setToken(result.wsCredential)
|
||||
if (result.sessionCookie != null) {
|
||||
AuthManager.setSessionCookie(result.sessionCookie)
|
||||
AuthManager.setWsAuthParam("ticket")
|
||||
} else {
|
||||
AuthManager.setSessionCookie(null)
|
||||
AuthManager.setWsAuthParam("token")
|
||||
}
|
||||
ApiClient.rebuild()
|
||||
_uiState.update { it.copy(isLoading = false, connectionSuccess = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the token by calling /api/status with it.
|
||||
*/
|
||||
private suspend fun connectTokenOnly(
|
||||
host: String,
|
||||
port: Int,
|
||||
token: String,
|
||||
): String? {
|
||||
if (token.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_token_required))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val tempApi = ApiClient.createTempService(host, port, token)
|
||||
val result = safeApiCall { tempApi.getSessions() }
|
||||
|
||||
return when (result) {
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
||||
token
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
val msg =
|
||||
when (val err = result.error) {
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.Http -> {
|
||||
when (err.code) {
|
||||
401 -> app.getString(R.string.connect_error_401)
|
||||
403 -> app.getString(R.string.connect_error_403)
|
||||
else -> app.getString(R.string.connect_error_http_code, err.code)
|
||||
}
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.Connection -> {
|
||||
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> {
|
||||
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = msg) }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with basic auth, then mint a WS ticket.
|
||||
* Returns the WS ticket and the session cookie for REST auth.
|
||||
*/
|
||||
private fun connectBasicAuth(
|
||||
host: String,
|
||||
port: Int,
|
||||
username: String,
|
||||
password: String,
|
||||
): ConnectResult? {
|
||||
if (username.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_username_required))
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (password.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = app.getString(R.string.auth_login_error_password_required))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val baseUrl = "http://$host:$port"
|
||||
val jsonBody = """{"provider":"basic","username":"$username","password":"$password","next":""}"""
|
||||
|
||||
try {
|
||||
// Step 1: Authenticate via the password login endpoint to get a session cookie
|
||||
val loginClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
val loginReq =
|
||||
Request.Builder()
|
||||
.url("$baseUrl/auth/password-login")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(null, jsonBody))
|
||||
.build()
|
||||
val loginResp = loginClient.newCall(loginReq).execute()
|
||||
|
||||
if (!loginResp.isSuccessful) {
|
||||
val msg =
|
||||
when (loginResp.code) {
|
||||
401 -> app.getString(R.string.connect_error_401)
|
||||
403 -> app.getString(R.string.connect_error_403)
|
||||
else -> app.getString(R.string.connect_error_http_code, loginResp.code)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = msg) }
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 2: Extract hermes_session_at cookie from Set-Cookie headers
|
||||
val setCookieHeaders = loginResp.headers("Set-Cookie")
|
||||
var sessionAccessToken: String? = null
|
||||
for (header in setCookieHeaders) {
|
||||
if (header.startsWith("hermes_session_at=")) {
|
||||
sessionAccessToken = header.split(";")[0].removePrefix("hermes_session_at=").trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionAccessToken.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Login succeeded but no session cookie received",
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val cookieHeader = "hermes_session_at=$sessionAccessToken"
|
||||
|
||||
// Step 3: Mint a WebSocket ticket using the session cookie
|
||||
val ticketClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val ticketReq =
|
||||
Request.Builder()
|
||||
.url("$baseUrl/api/auth/ws-ticket")
|
||||
.header("Cookie", cookieHeader)
|
||||
.post(RequestBody.create(null, "{}"))
|
||||
.build()
|
||||
val ticketResp = ticketClient.newCall(ticketReq).execute()
|
||||
|
||||
if (!ticketResp.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = app.getString(R.string.connect_error_http_code, ticketResp.code),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val ticketBody = ticketResp.body?.string() ?: ""
|
||||
val ticketMatch = Regex(""""ticket":"([^"]+)"""").find(ticketBody)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
|
||||
if (ticket.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to obtain WebSocket ticket",
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return ConnectResult(wsCredential = ticket, sessionCookie = sessionAccessToken)
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = app.getString(R.string.connect_error_connection_failed, e.message ?: ""),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AuthLoginViewModelFactory(
|
||||
private val app: Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return AuthLoginViewModel(app) as T
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.connect
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
onConnected: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConnectViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
run {
|
||||
val app = LocalContext.current.applicationContext as Application
|
||||
ConnectViewModelFactory(app)
|
||||
},
|
||||
),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) {
|
||||
onConnected()
|
||||
}
|
||||
}
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var showManualFields by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Hero brand
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bolt,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.notif_title),
|
||||
style =
|
||||
MaterialTheme.typography.displayMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.nav_drawer_subtitle),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Connection Profiles Dropdown / Selection
|
||||
if (state.profiles.isNotEmpty()) {
|
||||
var profilesExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { profilesExpanded = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
state.selectedProfile?.let { p ->
|
||||
stringResource(R.string.connect_selected_profile, p.name)
|
||||
} ?: stringResource(R.string.connect_action_select_profile),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = profilesExpanded,
|
||||
onDismissRequest = { profilesExpanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.connect_profile_none)) },
|
||||
onClick = {
|
||||
viewModel.selectProfile(null)
|
||||
profilesExpanded = false
|
||||
},
|
||||
)
|
||||
state.profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile.name) },
|
||||
onClick = {
|
||||
viewModel.selectProfile(profile)
|
||||
profilesExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing (primary path)
|
||||
Column(
|
||||
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,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
state.errorMessage?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Profile Save Options prior to connecting
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.saveProfile,
|
||||
onCheckedChange = viewModel::onSaveProfileChange,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.connect_save_profile),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = state.saveProfile) {
|
||||
OutlinedTextField(
|
||||
value = state.profileName,
|
||||
onValueChange = viewModel::onProfileNameChange,
|
||||
label = { Text(stringResource(R.string.connect_profile_name)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_profile_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = viewModel::connect,
|
||||
enabled = !state.isConnecting,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
) {
|
||||
if (state.isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.action_connect), style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { showManualFields = !showManualFields },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (showManualFields) {
|
||||
stringResource(R.string.connect_action_hide_manual)
|
||||
} else {
|
||||
stringResource(R.string.connect_action_show_manual)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showManualFields) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
value = state.token,
|
||||
onValueChange = viewModel::onTokenChange,
|
||||
label = { Text(stringResource(R.string.settings_field_token)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_token)) },
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
if (passwordVisible) {
|
||||
stringResource(R.string.content_desc_hide_token)
|
||||
} else {
|
||||
stringResource(R.string.content_desc_show_token)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.m57.hermescontrol.ui.landing
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
@Composable
|
||||
fun LandingScreen(
|
||||
onAuthLogin: () -> Unit,
|
||||
onPairingLogin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Hero icon
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bolt,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.landing_title),
|
||||
style =
|
||||
MaterialTheme.typography.displayMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.landing_subtitle),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onAuthLogin,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.landing_action_auth_login),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onPairingLogin,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.landing_action_pairing_login),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ enum class SettingsTab {
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
|
||||
) {
|
||||
@@ -810,6 +811,24 @@ fun SettingsScreen(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.logout()
|
||||
onLogout()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.settings_logout))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,14 @@ class SettingsViewModel : ViewModel() {
|
||||
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
|
||||
}
|
||||
|
||||
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||
fun logout() {
|
||||
AuthManager.setToken(null)
|
||||
AuthManager.setSessionCookie(null)
|
||||
AuthManager.setWsAuthParam("token")
|
||||
// Don't rebuild ApiClient here — let the navigation complete first
|
||||
}
|
||||
|
||||
/** All screens available for bottom-nav selection (name → display label). */
|
||||
val availableNavItems: List<Pair<String, Int>> =
|
||||
listOf(
|
||||
|
||||
Reference in new issue
Block a user