feat: optimize network stack (#404)

* feat: optimize network stack and connection management

Refactor OkHttp connection pools, consolidate Gson serialization, add network-aware WebSocket reconnects, and deduplicate concurrent requests.

* test: update mapping assertions for AuthExpired

* refactor: address PR review feedback on network stack

Simplify health check, improve authenticator cookie matching robustness, resolve nit imports, and add unit tests.
This commit is contained in:
M57 authored and GitHub committed 2026-06-27 00:45:48 +03:00
1 parent 9344c3bd6e
commit 85c4662537
19 files changed
+455 -206

No files matched your search

@@ -2,10 +2,12 @@ package com.m57.hermescontrol
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
class HermesControlApp : Application() {
override fun onCreate() {
super.onCreate()
AuthManager.init(this)
NetworkMonitor.init(this)
}
}
@@ -144,7 +144,10 @@ fun MainNavigation(sessionId: String? = null) {
ConnectionStatus.RECONNECTING,
-> LocalHermesStatusColors.current.warning
ConnectionStatus.DISCONNECTED -> LocalHermesStatusColors.current.error
ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
ConnectionStatus.AUTH_EXPIRED,
-> LocalHermesStatusColors.current.error
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -63,7 +63,7 @@ object AuthManager {
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
private val gson = com.google.gson.Gson()
private val gson = com.m57.hermescontrol.data.remote.OkHttpProvider.gson
/**
* Initialise the encrypted preferences.
@@ -4,11 +4,9 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
@@ -62,12 +60,10 @@ object ApiClient {
}
val tempOkHttp =
OkHttpClient
.Builder()
OkHttpProvider
.base
.newBuilder()
.addInterceptor(tempAuthInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val tempRetrofit =
@@ -75,7 +71,7 @@ object ApiClient {
.Builder()
.baseUrl("http://$host:$port/")
.client(tempOkHttp)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build()
return tempRetrofit.create(HermesApiService::class.java)
@@ -84,10 +80,6 @@ object ApiClient {
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
// B1 (Jun 18 2026, kanban t_afc1d26f): Level.BODY prints every request —
// including the Authorization: Bearer header from authInterceptor below —
// to logcat in plaintext. Gate on BuildConfig.DEBUG so release builds
// are silent; debug builds behave identically to before.
val logging =
HttpLoggingInterceptor().apply {
level =
@@ -126,20 +118,16 @@ object ApiClient {
}
}
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
val certificatePinner = CertificatePinner.Builder().build()
val okHttp =
OkHttpClient
.Builder()
OkHttpProvider
.base
.newBuilder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.authenticator(TokenRefreshAuthenticator)
.certificatePinner(certificatePinner)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val rf =
@@ -147,7 +135,7 @@ object ApiClient {
.Builder()
.baseUrl(AuthManager.baseUrl())
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build()
.also { retrofit = it }
@@ -0,0 +1,29 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object NetworkMonitor {
private val _isConnected = MutableStateFlow(true) // optimistic default
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
fun init(context: Context) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
_isConnected.value = cm.activeNetwork != null
cm.registerDefaultNetworkCallback(
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isConnected.value = true
}
override fun onLost(network: Network) {
_isConnected.value = false
}
},
)
}
}
@@ -27,6 +27,10 @@ sealed interface NetworkError {
val cause: IOException,
) : NetworkError
data class AuthExpired(
override val message: String = "Authentication token has expired. Please log in again.",
) : NetworkError
data class Unknown(
override val message: String,
val cause: Throwable,
@@ -34,10 +38,12 @@ sealed interface NetworkError {
}
fun mapHttpError(code: Int): NetworkError {
if (code == 401) {
return NetworkError.AuthExpired()
}
val message =
when (code) {
400 -> "Bad Request (HTTP 400): The server could not understand the request."
401 -> "Unauthorized (HTTP 401): Authentication token is invalid or expired."
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
404 -> "Not Found (HTTP 404): The requested resource could not be found."
405 -> "Method Not Allowed (HTTP 405)."
@@ -50,8 +56,22 @@ fun mapHttpError(code: Int): NetworkError {
return NetworkError.Http(code, message)
}
fun isRetryable(e: IOException): Boolean =
when (e) {
is java.net.UnknownHostException -> false
is javax.net.ssl.SSLException -> false
is java.net.ConnectException -> true
is java.net.SocketTimeoutException -> true
else -> true
}
fun jitteredBackoff(baseMs: Long): Long {
val jitter = (baseMs * 0.25 * (Math.random() * 2 - 1)).toLong()
return baseMs + jitter
}
suspend inline fun <reified T> safeApiCall(
retries: Int = 3,
retries: Int = 2,
crossinline call: suspend () -> Response<T>,
): NetworkResult<T> {
var lastException: IOException? = null
@@ -68,8 +88,8 @@ suspend inline fun <reified T> safeApiCall(
}
} else {
val code = response.code()
if (code in 500..599 && attempt < retries) {
val backoff = 500L * (1 shl attempt)
if ((code in 500..599 || code == 429) && attempt < retries) {
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
continue
}
@@ -77,7 +97,7 @@ suspend inline fun <reified T> safeApiCall(
}
} catch (e: IOException) {
lastException = e
if (attempt == retries) {
if (attempt == retries || !isRetryable(e)) {
return NetworkResult.Failure(
NetworkError.Connection(
"Network connection failure: ${e.message ?: "Unknown connection error"}",
@@ -85,7 +105,7 @@ suspend inline fun <reified T> safeApiCall(
),
)
}
val backoff = 500L * (1 shl attempt)
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
} catch (e: Exception) {
return NetworkResult.Failure(
@@ -0,0 +1,47 @@
package com.m57.hermescontrol.data.remote
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object OkHttpProvider {
// Single connection pool shared by ALL clients (REST, WS, probes)
// 5 idle connections, 30s keep-alive — tuned for single-server LAN
private val connectionPool = ConnectionPool(5, 30, TimeUnit.SECONDS)
// Base client: connection pool + sensible defaults
// Note: retryOnConnectionFailure(true) is enabled to allow OkHttp to recover from
// low-level connection/route failures (e.g. route timeouts, IPv4/IPv6 fallback). This is
// separate from safeApiCall's app-level retries (which add backoff delays and handle 5xx/429/timeouts).
val base: OkHttpClient =
OkHttpClient
.Builder()
.connectionPool(connectionPool)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
// WebSocket-optimized variant (infinite read timeout, ping interval)
val websocket: OkHttpClient =
base
.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
// Short-timeout variant for probes and ticket minting
val probe: OkHttpClient =
base
.newBuilder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
// Shared Gson — thread-safe, zero-config
val gson: Gson = GsonBuilder().create()
}
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
object TokenRefreshAuthenticator : Authenticator {
override fun authenticate(
route: Route?,
response: Response,
): Request? {
if (response.priorResponse != null) {
return null // Only retry once
}
val sessionCookie = AuthManager.getSessionCookie()
if (!sessionCookie.isNullOrBlank()) {
val requestCookie = response.request.header("Cookie")
val currentCookieHeader = "hermes_session_at=$sessionCookie"
if (requestCookie == null || !requestCookie.contains(currentCookieHeader)) {
return response.request
.newBuilder()
.header("Cookie", currentCookieHeader)
.build()
}
} else {
val token = AuthManager.getToken()
val requestAuth = response.request.header("Authorization")
val currentAuthHeader = "Bearer $token"
if (!token.isNullOrBlank() && (requestAuth == null || !requestAuth.contains(currentAuthHeader))) {
return response.request
.newBuilder()
.header("Authorization", currentAuthHeader)
.build()
}
}
return null
}
}
@@ -2,14 +2,16 @@ package com.m57.hermescontrol.data.ws
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.google.gson.Gson
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,15 +24,12 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -42,6 +41,8 @@ enum class ConnectionStatus {
CONNECTING,
CONNECTED,
RECONNECTING,
NO_NETWORK,
AUTH_EXPIRED,
}
/**
@@ -62,7 +63,6 @@ object HermesWsClient {
// ── Internal state (all access through synchronized / atomic) ────────
private val gson = Gson()
private val requestId = AtomicInteger(0)
private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false)
@@ -74,58 +74,84 @@ object HermesWsClient {
@Volatile
private var currentBackoff = INITIAL_BACKOFF_MS
// B4 (Jun 18 2026, kanban t_2b834d90): dedicated IO coroutine scope for
// the new single-Job reconnect scheduler below. SupervisorJob keeps one
// child failure from cancelling the scope; the scope itself lives as
// long as this singleton (object) exists.
private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile
private var reconnectJob: Job? = null
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
private val certificatePinner = CertificatePinner.Builder().build()
// ── Health and Ping/Pong tracking ────────────────────────────────────
private val okHttpClient =
OkHttpClient
.Builder()
.certificatePinner(certificatePinner)
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS)
.build()
@Volatile
var lastPongTimestamp: Long = 0L
private set
val isHealthy: Boolean
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
private var healthJob: Job? = null
private fun startHealthTracking() {
healthJob?.cancel()
lastPongTimestamp = System.currentTimeMillis()
healthJob =
wsScope.launch {
while (connected.get()) {
delay(30_000L)
if (connected.get() && System.currentTimeMillis() - lastPongTimestamp > 60_000L) {
Log.w(TAG, "WebSocket connection appears unhealthy (no frames received for > 60s)")
}
}
}
}
private fun stopHealthTracking() {
healthJob?.cancel()
healthJob = null
}
// ── Public observable stream ─────────────────────────────────────────
private val rawMessages =
MutableSharedFlow<String>(
extraBufferCapacity = 256,
extraBufferCapacity = 512,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> =
rawMessages
.buffer()
.buffer(Channel.BUFFERED)
.map { text ->
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val rpc = OkHttpProvider.gson.fromJson(text, JsonRpcResponse::class.java)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text)
}
}
.flowOn(Dispatchers.IO)
}.flowOn(Dispatchers.Default) // CPU-bound
.shareIn(wsScope, SharingStarted.Eagerly)
// ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
/** Observable connection status: DISCONNECTED / CONNECTING / CONNECTED / RECONNECTING */
/** Observable connection status */
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
init {
// Monitor network state to trigger immediate reconnect when network is restored
wsScope.launch {
NetworkMonitor.isConnected.collect { connected ->
if (connected && !isConnected && !intentionalClose.get() && AuthManager.isAutoReconnect()) {
Log.d(TAG, "Network restored — triggering immediate reconnect")
currentBackoff = INITIAL_BACKOFF_MS
reconnectJob?.cancel()
openSocket()
}
}
}
}
// ── Connection helpers ────────────────────────────────────────────────
@VisibleForTesting
@@ -152,18 +178,13 @@ object HermesWsClient {
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 client = OkHttpProvider.probe
val request =
Request.Builder()
Request
.Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.header("Cookie", "hermes_session_at=$sessionCookie")
.post("{}".toRequestBody())
@@ -190,6 +211,7 @@ object HermesWsClient {
intentionalClose.set(true)
reconnectJob?.cancel()
reconnectJob = null
stopHealthTracking()
webSocket?.close(1000, "Client closed")
webSocket = null
connected.set(false)
@@ -210,10 +232,7 @@ object HermesWsClient {
val id = requestId.incrementAndGet().toString()
onSent?.invoke(id)
val request = JsonRpcRequest(id = id, method = method, params = params)
val json = gson.toJson(request)
// B2 (Jun 18 2026, kanban t_8884db16): outgoing JSON contains user
// prompts (PromptSubmit params include the `text` field) — never
// stream to logcat in release builds.
val json = OkHttpProvider.gson.toJson(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
val ws = webSocket
if (ws != null && connected.get()) {
@@ -242,13 +261,11 @@ object HermesWsClient {
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.
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
webSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl())
}
private fun scheduleReconnect() {
@@ -258,6 +275,11 @@ object HermesWsClient {
_connectionStatus.value = ConnectionStatus.DISCONNECTED
return
}
if (!NetworkMonitor.isConnected.value) {
Log.d(TAG, "No network available — delaying reconnect scheduling")
_connectionStatus.value = ConnectionStatus.NO_NETWORK
return
}
val delay = currentBackoff
currentBackoff =
(currentBackoff * BACKOFF_MULTIPLIER)
@@ -265,22 +287,6 @@ object HermesWsClient {
.coerceAtMost(MAX_BACKOFF_MS)
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${delay}ms …")
// B4 (Jun 18 2026, kanban t_2b834d90): replaced raw Thread{ sleep;
// check; openSocket() } with a single cancellable coroutine Job.
//
// Old code had two failure modes:
// 1. TOCTOU: line 149 `if (!intentionalClose && !connected)` was
// not atomic with line 150 `openSocket()`. Two reconnect threads
// spawned from a rapid onFailure/onClosed cycle could both pass
// the gate and both call openSocket() — the second overwrote
// `webSocket` without closing the first, leaking an orphaned
// listener.
// 2. Unbounded thread spawn: a flaky network multiplied the
// daemon-thread count. Backoff grew but old threads weren't
// cancelled.
//
// New behavior: cancel any in-flight reconnect before scheduling the
// next one — only one reconnect coroutine exists at any time.
reconnectJob?.cancel()
reconnectJob =
wsScope.launch {
@@ -302,6 +308,7 @@ object HermesWsClient {
connected.set(true)
_connectionStatus.value = ConnectionStatus.CONNECTED
currentBackoff = INITIAL_BACKOFF_MS
startHealthTracking()
while (messageQueue.isNotEmpty()) {
val msg = messageQueue.poll()
@@ -316,11 +323,12 @@ object HermesWsClient {
webSocket: WebSocket,
text: String,
) {
// B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains
// AI reply tokens and tool output — never stream to logcat in
// release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
rawMessages.tryEmit(text)
lastPongTimestamp = System.currentTimeMillis()
val emitted = rawMessages.tryEmit(text)
if (!emitted && BuildConfig.DEBUG) {
Log.w(TAG, "WebSocket message dropped due to buffer overflow")
}
}
override fun onClosing(
@@ -339,8 +347,13 @@ object HermesWsClient {
) {
Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false)
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
stopHealthTracking()
if (code == 4001 || reason.contains("unauthorized", ignoreCase = true)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else {
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
}
}
override fun onFailure(
@@ -350,8 +363,17 @@ object HermesWsClient {
) {
Log.e(TAG, "WebSocket failure: ${t.message}", t)
connected.set(false)
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
stopHealthTracking()
val code = response?.code ?: 0
if (code == 401 || t.message?.contains(
"401",
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else {
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
}
}
}
}
@@ -48,7 +48,9 @@ data class AuthLoginUiState(
val errorMessage: String? = null,
)
class AuthLoginViewModel(private val app: Application) : ViewModel() {
class AuthLoginViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthLoginUiState())
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
@@ -57,11 +59,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
}
private val probeClient: OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) }
@@ -150,7 +148,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// 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 req =
Request
.Builder()
.url("$baseUrl/api/status")
.get()
.build()
val resp = probeClient.newCall(req).execute()
resp.isSuccessful
} catch (e: Exception) {
@@ -163,7 +166,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// 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 req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute()
val code = resp.code
val location = resp.header("location", "")
@@ -178,7 +186,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
var extractedToken: String? = null
if (!needsBasicAuth) {
try {
val req = Request.Builder().url(baseUrl).get().build()
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
@@ -231,12 +244,18 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
val token = connectTokenOnly(state.host, port, state.token)
if (token != null) ConnectResult(wsCredential = token) else null
}
DashboardAuthMode.BASIC_AUTH ->
DashboardAuthMode.BASIC_AUTH -> {
connectBasicAuth(state.host, port, state.username, state.password)
}
DashboardAuthMode.ALL -> {
connectBasicAuth(state.host, port, state.username, state.password)
}
null -> null
null -> {
null
}
}
}
@@ -279,6 +298,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
token
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
val msg =
when (val err = result.error) {
@@ -289,9 +309,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
else -> app.getString(R.string.connect_error_http_code, err.code)
}
}
is com.m57.hermescontrol.data.remote.NetworkError.AuthExpired -> {
app.getString(R.string.connect_error_401)
}
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 ?: "")
}
@@ -331,14 +357,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
try {
// Step 1: Authenticate via the password login endpoint to get a session cookie
val loginClient =
OkHttpClient.Builder()
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.followRedirects(false)
.build()
val loginReq =
Request.Builder()
Request
.Builder()
.url("$baseUrl/auth/password-login")
.header("Content-Type", "application/json")
.post(jsonBody.toRequestBody())
@@ -380,13 +407,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient =
OkHttpClient.Builder()
com.m57.hermescontrol.data.remote.OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val ticketReq =
Request.Builder()
Request
.Builder()
.url("$baseUrl/api/auth/ws-ticket")
.header("Cookie", cookieHeader)
.post("{}".toRequestBody())
@@ -434,7 +463,5 @@ 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
}
override fun <T : ViewModel> create(modelClass: Class<T>): T = AuthLoginViewModel(app) as T
}
@@ -4,11 +4,11 @@ import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -775,16 +775,14 @@ class ChatViewModel(
}
@Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? {
return try {
val gson = com.google.gson.Gson()
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try {
val json = gson.toJson(map)
gson.fromJson(json, CommandCatalog::class.java)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e)
null
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
@@ -1186,6 +1184,6 @@ class ChatViewModel(
}
companion object {
private val gson = Gson()
private val gson = OkHttpProvider.gson
}
}
@@ -216,8 +216,8 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
com.m57.hermescontrol.data.remote.OkHttpProvider
.gson
.toJson(it)
} ?: ""
val toolMessage =
@@ -264,8 +264,8 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
com.m57.hermescontrol.data.remote.OkHttpProvider
.gson
.toJson(it)
} ?: ""
val messages = state.messages.toMutableList()
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -12,17 +13,19 @@ interface ToastHost {
}
inline fun <T> ViewModel.safeLaunchLoad(
currentJob: Job? = null,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
) {
): Job {
if (currentJob?.isActive == true) return currentJob
onStart()
viewModelScope.launch {
return viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
when (result) {
is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message ?: "Unknown error")
is NetworkResult.Failure -> onError(result.error.message)
}
}
}
@@ -31,7 +31,9 @@ data class ConnectUiState(
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
)
class ConnectViewModel(private val app: Application) : ViewModel() {
class ConnectViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConnectUiState())
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
@@ -206,26 +208,44 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
}
}
is NetworkError.AuthExpired -> {
AuthManager.setToken(null)
if (AuthManager.getSelectedProfileId() != null) {
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
}
app.getString(R.string.connect_error_401)
}
is NetworkError.Connection -> {
val causeMessage = err.cause.message ?: ""
when {
causeMessage.contains(
"timeout",
true,
) -> app.getString(R.string.connect_error_timeout)
) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains(
"refused",
true,
) -> app.getString(R.string.connect_error_refused)
) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains(
"resolve",
true,
) -> app.getString(R.string.connect_error_resolve)
else ->
) -> {
app.getString(R.string.connect_error_resolve)
}
else -> {
String.format(
app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "",
)
}
}
}
@@ -235,20 +255,30 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
causeMessage.contains(
"timeout",
true,
) -> app.getString(R.string.connect_error_timeout)
) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains(
"refused",
true,
) -> app.getString(R.string.connect_error_refused)
) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains(
"resolve",
true,
) -> app.getString(R.string.connect_error_resolve)
else ->
) -> {
app.getString(R.string.connect_error_resolve)
}
else -> {
String.format(
app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "",
)
}
}
}
}
@@ -359,7 +389,5 @@ class ConnectViewModelFactory(
private val app: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConnectViewModel(app) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): T = ConnectViewModel(app) as T
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,26 +24,32 @@ data class CronJobsUiState(
val toastMessage: String? = null,
)
class CronJobsViewModel : ViewModel(), ToastHost {
class CronJobsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadCronJobs() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: $errorMsg",
)
}
},
)
}
fun pauseCronJob(id: String) {
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +27,8 @@ class SessionsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
/** Page size sent to the server — matches the default the gateway uses. */
private companion object {
const val PAGE_SIZE = 20
@@ -33,37 +36,39 @@ class SessionsViewModel : ViewModel() {
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
fun loadSessions() {
safeLaunchLoad(
apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
sessions = data?.sessions.orEmpty(),
total = data?.total ?: 0,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
sessions = data?.sessions.orEmpty(),
total = data?.total ?: 0,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
}
},
)
}
/** Load the next page and append to the existing session list. */
@@ -92,6 +97,7 @@ class SessionsViewModel : ViewModel() {
)
}
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -29,26 +30,32 @@ data class SkillsUiState(
val saveContentSuccess: Boolean = false,
)
class SkillsViewModel : ViewModel(), ToastHost {
class SkillsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadSkills() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: $errorMsg",
)
}
},
)
}
fun toggleSkill(skill: Skill) {