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 android.app.Application
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
class HermesControlApp : Application() { class HermesControlApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AuthManager.init(this) AuthManager.init(this)
NetworkMonitor.init(this)
} }
} }
@@ -144,7 +144,10 @@ fun MainNavigation(sessionId: String? = null) {
ConnectionStatus.RECONNECTING, ConnectionStatus.RECONNECTING,
-> LocalHermesStatusColors.current.warning -> LocalHermesStatusColors.current.warning
ConnectionStatus.DISCONNECTED -> LocalHermesStatusColors.current.error ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
ConnectionStatus.AUTH_EXPIRED,
-> LocalHermesStatusColors.current.error
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -63,7 +63,7 @@ object AuthManager {
private val _tokenFlow = MutableStateFlow<String?>(null) private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow() 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. * Initialise the encrypted preferences.
@@ -4,11 +4,9 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner import okhttp3.CertificatePinner
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/** /**
* Provides a Retrofit-backed [HermesApiService]. * Provides a Retrofit-backed [HermesApiService].
@@ -62,12 +60,10 @@ object ApiClient {
} }
val tempOkHttp = val tempOkHttp =
OkHttpClient OkHttpProvider
.Builder() .base
.newBuilder()
.addInterceptor(tempAuthInterceptor) .addInterceptor(tempAuthInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build() .build()
val tempRetrofit = val tempRetrofit =
@@ -75,7 +71,7 @@ object ApiClient {
.Builder() .Builder()
.baseUrl("http://$host:$port/") .baseUrl("http://$host:$port/")
.client(tempOkHttp) .client(tempOkHttp)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build() .build()
return tempRetrofit.create(HermesApiService::class.java) return tempRetrofit.create(HermesApiService::class.java)
@@ -84,10 +80,6 @@ object ApiClient {
// ── Internal ───────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService { 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 = val logging =
HttpLoggingInterceptor().apply { HttpLoggingInterceptor().apply {
level = 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 certificatePinner = CertificatePinner.Builder().build()
val okHttp = val okHttp =
OkHttpClient OkHttpProvider
.Builder() .base
.newBuilder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(logging) .addInterceptor(logging)
.authenticator(TokenRefreshAuthenticator)
.certificatePinner(certificatePinner) .certificatePinner(certificatePinner)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build() .build()
val rf = val rf =
@@ -147,7 +135,7 @@ object ApiClient {
.Builder() .Builder()
.baseUrl(AuthManager.baseUrl()) .baseUrl(AuthManager.baseUrl())
.client(okHttp) .client(okHttp)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
.build() .build()
.also { retrofit = it } .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, val cause: IOException,
) : NetworkError ) : NetworkError
data class AuthExpired(
override val message: String = "Authentication token has expired. Please log in again.",
) : NetworkError
data class Unknown( data class Unknown(
override val message: String, override val message: String,
val cause: Throwable, val cause: Throwable,
@@ -34,10 +38,12 @@ sealed interface NetworkError {
} }
fun mapHttpError(code: Int): NetworkError { fun mapHttpError(code: Int): NetworkError {
if (code == 401) {
return NetworkError.AuthExpired()
}
val message = val message =
when (code) { when (code) {
400 -> "Bad Request (HTTP 400): The server could not understand the request." 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." 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." 404 -> "Not Found (HTTP 404): The requested resource could not be found."
405 -> "Method Not Allowed (HTTP 405)." 405 -> "Method Not Allowed (HTTP 405)."
@@ -50,8 +56,22 @@ fun mapHttpError(code: Int): NetworkError {
return NetworkError.Http(code, message) 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( suspend inline fun <reified T> safeApiCall(
retries: Int = 3, retries: Int = 2,
crossinline call: suspend () -> Response<T>, crossinline call: suspend () -> Response<T>,
): NetworkResult<T> { ): NetworkResult<T> {
var lastException: IOException? = null var lastException: IOException? = null
@@ -68,8 +88,8 @@ suspend inline fun <reified T> safeApiCall(
} }
} else { } else {
val code = response.code() val code = response.code()
if (code in 500..599 && attempt < retries) { if ((code in 500..599 || code == 429) && attempt < retries) {
val backoff = 500L * (1 shl attempt) val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff) delay(backoff)
continue continue
} }
@@ -77,7 +97,7 @@ suspend inline fun <reified T> safeApiCall(
} }
} catch (e: IOException) { } catch (e: IOException) {
lastException = e lastException = e
if (attempt == retries) { if (attempt == retries || !isRetryable(e)) {
return NetworkResult.Failure( return NetworkResult.Failure(
NetworkError.Connection( NetworkError.Connection(
"Network connection failure: ${e.message ?: "Unknown connection error"}", "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) delay(backoff)
} catch (e: Exception) { } catch (e: Exception) {
return NetworkResult.Failure( 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 android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.google.gson.Gson
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,15 +24,12 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -42,6 +41,8 @@ enum class ConnectionStatus {
CONNECTING, CONNECTING,
CONNECTED, CONNECTED,
RECONNECTING, RECONNECTING,
NO_NETWORK,
AUTH_EXPIRED,
} }
/** /**
@@ -62,7 +63,6 @@ object HermesWsClient {
// ── Internal state (all access through synchronized / atomic) ──────── // ── Internal state (all access through synchronized / atomic) ────────
private val gson = Gson()
private val requestId = AtomicInteger(0) private val requestId = AtomicInteger(0)
private val connected = AtomicBoolean(false) private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false) private val intentionalClose = AtomicBoolean(false)
@@ -74,58 +74,84 @@ object HermesWsClient {
@Volatile @Volatile
private var currentBackoff = INITIAL_BACKOFF_MS 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()) private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile @Volatile
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
// SEC-11: No certificate pinning is configured by default since the app // ── Health and Ping/Pong tracking ────────────────────────────────────
// 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()
private val okHttpClient = @Volatile
OkHttpClient var lastPongTimestamp: Long = 0L
.Builder() private set
.certificatePinner(certificatePinner)
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever val isHealthy: Boolean
.pingInterval(30, TimeUnit.SECONDS) get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
.build()
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 ───────────────────────────────────────── // ── Public observable stream ─────────────────────────────────────────
private val rawMessages = private val rawMessages =
MutableSharedFlow<String>( MutableSharedFlow<String>(
extraBufferCapacity = 256, extraBufferCapacity = 512,
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */ /** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = val events: SharedFlow<WsEvent> =
rawMessages rawMessages
.buffer() .buffer(Channel.BUFFERED)
.map { text -> .map { text ->
try { try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java) val rpc = OkHttpProvider.gson.fromJson(text, JsonRpcResponse::class.java)
EventParser.parse(rpc, text) EventParser.parse(rpc, text)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e) Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text) WsEvent.Unknown(text)
} }
} }.flowOn(Dispatchers.Default) // CPU-bound
.flowOn(Dispatchers.IO)
.shareIn(wsScope, SharingStarted.Eagerly) .shareIn(wsScope, SharingStarted.Eagerly)
// ── Connection status flow ────────────────────────────────────────── // ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
/** Observable connection status: DISCONNECTED / CONNECTING / CONNECTED / RECONNECTING */ /** Observable connection status */
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow() 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 ──────────────────────────────────────────────── // ── Connection helpers ────────────────────────────────────────────────
@VisibleForTesting @VisibleForTesting
@@ -152,18 +178,13 @@ object HermesWsClient {
private fun refreshWsTicketIfNeeded() { private fun refreshWsTicketIfNeeded() {
val sessionCookie = AuthManager.getSessionCookie() val sessionCookie = AuthManager.getSessionCookie()
if (sessionCookie.isNullOrBlank()) { if (sessionCookie.isNullOrBlank()) {
// Loopback mode — the stored token IS a session token, not a
// WS ticket, so no refresh needed.
return return
} }
try { try {
val client = val client = OkHttpProvider.probe
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
val request = val request =
Request.Builder() Request
.Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket") .url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.header("Cookie", "hermes_session_at=$sessionCookie") .header("Cookie", "hermes_session_at=$sessionCookie")
.post("{}".toRequestBody()) .post("{}".toRequestBody())
@@ -190,6 +211,7 @@ object HermesWsClient {
intentionalClose.set(true) intentionalClose.set(true)
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
stopHealthTracking()
webSocket?.close(1000, "Client closed") webSocket?.close(1000, "Client closed")
webSocket = null webSocket = null
connected.set(false) connected.set(false)
@@ -210,10 +232,7 @@ object HermesWsClient {
val id = requestId.incrementAndGet().toString() val id = requestId.incrementAndGet().toString()
onSent?.invoke(id) onSent?.invoke(id)
val request = JsonRpcRequest(id = id, method = method, params = params) val request = JsonRpcRequest(id = id, method = method, params = params)
val json = gson.toJson(request) val json = OkHttpProvider.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.
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
val ws = webSocket val ws = webSocket
if (ws != null && connected.get()) { if (ws != null && connected.get()) {
@@ -242,13 +261,11 @@ object HermesWsClient {
private fun openSocket() { private fun openSocket() {
refreshWsTicketIfNeeded() refreshWsTicketIfNeeded()
val url = AuthManager.wsUrl() 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") val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl()) webSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl())
} }
private fun scheduleReconnect() { private fun scheduleReconnect() {
@@ -258,6 +275,11 @@ object HermesWsClient {
_connectionStatus.value = ConnectionStatus.DISCONNECTED _connectionStatus.value = ConnectionStatus.DISCONNECTED
return return
} }
if (!NetworkMonitor.isConnected.value) {
Log.d(TAG, "No network available — delaying reconnect scheduling")
_connectionStatus.value = ConnectionStatus.NO_NETWORK
return
}
val delay = currentBackoff val delay = currentBackoff
currentBackoff = currentBackoff =
(currentBackoff * BACKOFF_MULTIPLIER) (currentBackoff * BACKOFF_MULTIPLIER)
@@ -265,22 +287,6 @@ object HermesWsClient {
.coerceAtMost(MAX_BACKOFF_MS) .coerceAtMost(MAX_BACKOFF_MS)
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${delay}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?.cancel()
reconnectJob = reconnectJob =
wsScope.launch { wsScope.launch {
@@ -302,6 +308,7 @@ object HermesWsClient {
connected.set(true) connected.set(true)
_connectionStatus.value = ConnectionStatus.CONNECTED _connectionStatus.value = ConnectionStatus.CONNECTED
currentBackoff = INITIAL_BACKOFF_MS currentBackoff = INITIAL_BACKOFF_MS
startHealthTracking()
while (messageQueue.isNotEmpty()) { while (messageQueue.isNotEmpty()) {
val msg = messageQueue.poll() val msg = messageQueue.poll()
@@ -316,11 +323,12 @@ object HermesWsClient {
webSocket: WebSocket, webSocket: WebSocket,
text: String, 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") 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( override fun onClosing(
@@ -339,8 +347,13 @@ object HermesWsClient {
) { ) {
Log.i(TAG, "WebSocket closed: $code $reason") Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false) connected.set(false)
_connectionStatus.value = ConnectionStatus.RECONNECTING stopHealthTracking()
scheduleReconnect() if (code == 4001 || reason.contains("unauthorized", ignoreCase = true)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else {
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
}
} }
override fun onFailure( override fun onFailure(
@@ -350,8 +363,17 @@ object HermesWsClient {
) { ) {
Log.e(TAG, "WebSocket failure: ${t.message}", t) Log.e(TAG, "WebSocket failure: ${t.message}", t)
connected.set(false) connected.set(false)
_connectionStatus.value = ConnectionStatus.RECONNECTING stopHealthTracking()
scheduleReconnect() 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, val errorMessage: String? = null,
) )
class AuthLoginViewModel(private val app: Application) : ViewModel() { class AuthLoginViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthLoginUiState()) private val _uiState = MutableStateFlow(AuthLoginUiState())
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
@@ -57,11 +59,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
} }
private val probeClient: OkHttpClient = private val probeClient: OkHttpClient =
OkHttpClient.Builder() com.m57.hermescontrol.data.remote.OkHttpProvider.probe
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
fun onHostChange(value: String) { fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) } _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) // Step 1: Check if dashboard is reachable via /api/status (always public)
val statusOk = val statusOk =
try { 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() val resp = probeClient.newCall(req).execute()
resp.isSuccessful resp.isSuccessful
} catch (e: Exception) { } 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 // Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA
val needsBasicAuth = val needsBasicAuth =
try { try {
val req = Request.Builder().url(baseUrl).get().build() val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute() val resp = probeClient.newCall(req).execute()
val code = resp.code val code = resp.code
val location = resp.header("location", "") val location = resp.header("location", "")
@@ -178,7 +186,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
var extractedToken: String? = null var extractedToken: String? = null
if (!needsBasicAuth) { if (!needsBasicAuth) {
try { try {
val req = Request.Builder().url(baseUrl).get().build() val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute() val resp = probeClient.newCall(req).execute()
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
// Extract __HERMES_SESSION_TOKEN__ from the SPA HTML // 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) val token = connectTokenOnly(state.host, port, state.token)
if (token != null) ConnectResult(wsCredential = token) else null if (token != null) ConnectResult(wsCredential = token) else null
} }
DashboardAuthMode.BASIC_AUTH ->
DashboardAuthMode.BASIC_AUTH -> {
connectBasicAuth(state.host, port, state.username, state.password) connectBasicAuth(state.host, port, state.username, state.password)
}
DashboardAuthMode.ALL -> { DashboardAuthMode.ALL -> {
connectBasicAuth(state.host, port, state.username, state.password) 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 -> { is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
token token
} }
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
val msg = val msg =
when (val err = result.error) { 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) 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 -> { is com.m57.hermescontrol.data.remote.NetworkError.Connection -> {
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
} }
is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> { is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> {
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "") app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
} }
@@ -331,14 +357,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
try { try {
// Step 1: Authenticate via the password login endpoint to get a session cookie // Step 1: Authenticate via the password login endpoint to get a session cookie
val loginClient = val loginClient =
OkHttpClient.Builder() com.m57.hermescontrol.data.remote.OkHttpProvider.probe
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS)
.followRedirects(false)
.build() .build()
val loginReq = val loginReq =
Request.Builder() Request
.Builder()
.url("$baseUrl/auth/password-login") .url("$baseUrl/auth/password-login")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.post(jsonBody.toRequestBody()) .post(jsonBody.toRequestBody())
@@ -380,13 +407,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// Step 3: Mint a WebSocket ticket using the session cookie // Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient = val ticketClient =
OkHttpClient.Builder() com.m57.hermescontrol.data.remote.OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS)
.build() .build()
val ticketReq = val ticketReq =
Request.Builder() Request
.Builder()
.url("$baseUrl/api/auth/ws-ticket") .url("$baseUrl/api/auth/ws-ticket")
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.post("{}".toRequestBody()) .post("{}".toRequestBody())
@@ -434,7 +463,5 @@ class AuthLoginViewModelFactory(
private val app: Application, private val app: Application,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T = AuthLoginViewModel(app) as T
return AuthLoginViewModel(app) as T
}
} }
@@ -4,11 +4,11 @@ import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult 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.remote.safeApiCall
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -775,16 +775,14 @@ class ChatViewModel(
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? { private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
return try { try {
val gson = com.google.gson.Gson()
val json = gson.toJson(map) val json = gson.toJson(map)
gson.fromJson(json, CommandCatalog::class.java) gson.fromJson(json, CommandCatalog::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e) Log.e(TAG, "Failed to parse command catalog", e)
null null
} }
}
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return if (sessionId == _uiState.value.currentSessionId) return
@@ -1186,6 +1184,6 @@ class ChatViewModel(
} }
companion object { companion object {
private val gson = Gson() private val gson = OkHttpProvider.gson
} }
} }
@@ -216,8 +216,8 @@ object ChatWsEventReducer {
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
com.google.gson com.m57.hermescontrol.data.remote.OkHttpProvider
.Gson() .gson
.toJson(it) .toJson(it)
} ?: "" } ?: ""
val toolMessage = val toolMessage =
@@ -264,8 +264,8 @@ object ChatWsEventReducer {
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
com.google.gson com.m57.hermescontrol.data.remote.OkHttpProvider
.Gson() .gson
.toJson(it) .toJson(it)
} ?: "" } ?: ""
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -12,17 +13,19 @@ interface ToastHost {
} }
inline fun <T> ViewModel.safeLaunchLoad( inline fun <T> ViewModel.safeLaunchLoad(
currentJob: Job? = null,
crossinline apiCall: suspend () -> NetworkResult<T>, crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit, crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit, crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit, crossinline onError: (String) -> Unit,
) { ): Job {
if (currentJob?.isActive == true) return currentJob
onStart() onStart()
viewModelScope.launch { return viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } val result = withContext(Dispatchers.IO) { apiCall() }
when (result) { when (result) {
is NetworkResult.Success -> onSuccess(result.data) 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, 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()) private val _uiState = MutableStateFlow(ConnectUiState())
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow() 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 -> { is NetworkError.Connection -> {
val causeMessage = err.cause.message ?: "" val causeMessage = err.cause.message ?: ""
when { when {
causeMessage.contains( causeMessage.contains(
"timeout", "timeout",
true, true,
) -> app.getString(R.string.connect_error_timeout) ) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains( causeMessage.contains(
"refused", "refused",
true, true,
) -> app.getString(R.string.connect_error_refused) ) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains( causeMessage.contains(
"resolve", "resolve",
true, true,
) -> app.getString(R.string.connect_error_resolve) ) -> {
else -> app.getString(R.string.connect_error_resolve)
}
else -> {
String.format( String.format(
app.getString(R.string.connect_error_connection_failed), app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "", err.cause.message ?: "",
) )
}
} }
} }
@@ -235,20 +255,30 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
causeMessage.contains( causeMessage.contains(
"timeout", "timeout",
true, true,
) -> app.getString(R.string.connect_error_timeout) ) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains( causeMessage.contains(
"refused", "refused",
true, true,
) -> app.getString(R.string.connect_error_refused) ) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains( causeMessage.contains(
"resolve", "resolve",
true, true,
) -> app.getString(R.string.connect_error_resolve) ) -> {
else -> app.getString(R.string.connect_error_resolve)
}
else -> {
String.format( String.format(
app.getString(R.string.connect_error_connection_failed), app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "", err.cause.message ?: "",
) )
}
} }
} }
} }
@@ -359,7 +389,5 @@ class ConnectViewModelFactory(
private val app: Application, private val app: Application,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T = ConnectViewModel(app) as T
return 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.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -23,26 +24,32 @@ data class CronJobsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class CronJobsViewModel : ViewModel(), ToastHost { class CronJobsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(CronJobsUiState()) private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow() val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadCronJobs() { fun loadCronJobs() {
safeLaunchLoad( loadJob =
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } }, safeLaunchLoad(
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, currentJob = loadJob,
onSuccess = { data -> apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) } onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
}, onSuccess = { data ->
onError = { errorMsg -> _uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
_uiState.update { },
it.copy( onError = { errorMsg ->
isLoading = false, _uiState.update {
errorMessage = "Failed to load cron jobs: $errorMsg", it.copy(
) isLoading = false,
} errorMessage = "Failed to load cron jobs: $errorMsg",
}, )
) }
},
)
} }
fun pauseCronJob(id: String) { 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.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +27,8 @@ class SessionsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState()) private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
/** Page size sent to the server — matches the default the gateway uses. */ /** Page size sent to the server — matches the default the gateway uses. */
private companion object { private companion object {
const val PAGE_SIZE = 20 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. */ /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
fun loadSessions() { fun loadSessions() {
safeLaunchLoad( loadJob =
apiCall = { safeLaunchLoad(
safeApiCall { currentJob = loadJob,
ApiClient.hermesApi.getSessions( apiCall = {
limit = PAGE_SIZE, safeApiCall {
offset = 0, ApiClient.hermesApi.getSessions(
order = "recent", limit = PAGE_SIZE,
) offset = 0,
} order = "recent",
}, )
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, }
onSuccess = { data -> },
_uiState.update { onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
it.copy( onSuccess = { data ->
isLoading = false, _uiState.update {
isLoadingMore = false, it.copy(
sessions = data?.sessions.orEmpty(), isLoading = false,
total = data?.total ?: 0, isLoadingMore = false,
) sessions = data?.sessions.orEmpty(),
} total = data?.total ?: 0,
}, )
onError = { errorMsg -> }
_uiState.update { },
it.copy( onError = { errorMsg ->
isLoading = false, _uiState.update {
isLoadingMore = false, it.copy(
errorMessage = "Failed to load sessions: $errorMsg", isLoading = false,
) isLoadingMore = false,
} errorMessage = "Failed to load sessions: $errorMsg",
}, )
) }
},
)
} }
/** Load the next page and append to the existing session list. */ /** 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 -> { is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( 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.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -29,26 +30,32 @@ data class SkillsUiState(
val saveContentSuccess: Boolean = false, val saveContentSuccess: Boolean = false,
) )
class SkillsViewModel : ViewModel(), ToastHost { class SkillsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState()) private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadSkills() { fun loadSkills() {
safeLaunchLoad( loadJob =
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } }, safeLaunchLoad(
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, currentJob = loadJob,
onSuccess = { data -> apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) } onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
}, onSuccess = { data ->
onError = { errorMsg -> _uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
_uiState.update { },
it.copy( onError = { errorMsg ->
isLoading = false, _uiState.update {
errorMessage = "Failed to load skills: $errorMsg", it.copy(
) isLoading = false,
} errorMessage = "Failed to load skills: $errorMsg",
}, )
) }
},
)
} }
fun toggleSkill(skill: Skill) { fun toggleSkill(skill: Skill) {
@@ -15,7 +15,6 @@ class NetworkResultTest {
val codesAndExpectedMessages = val codesAndExpectedMessages =
mapOf( mapOf(
400 to "Bad Request (HTTP 400): The server could not understand the request.", 400 to "Bad Request (HTTP 400): The server could not understand the request.",
401 to "Unauthorized (HTTP 401): Authentication token is invalid or expired.",
403 to "Forbidden (HTTP 403): You do not have permission to access this resource.", 403 to "Forbidden (HTTP 403): You do not have permission to access this resource.",
404 to "Not Found (HTTP 404): The requested resource could not be found.", 404 to "Not Found (HTTP 404): The requested resource could not be found.",
405 to "Method Not Allowed (HTTP 405).", 405 to "Method Not Allowed (HTTP 405).",
@@ -35,6 +34,13 @@ class NetworkResultTest {
} }
} }
@Test
fun testMapHttpError_authExpired() {
val error = mapHttpError(401)
assertTrue(error is NetworkError.AuthExpired)
assertEquals("Authentication token has expired. Please log in again.", error.message)
}
@Test @Test
fun testMapHttpError_unknownCode() { fun testMapHttpError_unknownCode() {
val error = mapHttpError(418) val error = mapHttpError(418)
@@ -44,6 +50,24 @@ class NetworkResultTest {
assertEquals("HTTP Error 418: Unexpected server response.", error.message) assertEquals("HTTP Error 418: Unexpected server response.", error.message)
} }
@Test
fun testIsRetryable() {
assertTrue(!isRetryable(java.net.UnknownHostException("Host not found")))
assertTrue(!isRetryable(javax.net.ssl.SSLException("SSL failed")))
assertTrue(isRetryable(java.net.ConnectException("Connection refused")))
assertTrue(isRetryable(java.net.SocketTimeoutException("Timeout")))
assertTrue(isRetryable(IOException("Generic IO error")))
}
@Test
fun testJitteredBackoff() {
val baseMs = 1000L
for (i in 0..100) {
val backoff = jitteredBackoff(baseMs)
assertTrue(backoff in 750..1250)
}
}
@Test @Test
fun testSafeApiCall_successWithBody() = fun testSafeApiCall_successWithBody() =
runBlocking { runBlocking {
+2 -4
View File
@@ -30,10 +30,8 @@
platformVersions = [ "36" "35" "34" ]; platformVersions = [ "36" "35" "34" ];
# For the emulator # For the emulator
includeEmulator = true; includeEmulator = false;
includeSystemImages = true; includeSystemImages = false;
systemImageTypes = [ "google_apis" ];
abiVersions = [ "x86_64" ];
# NDK (not needed for this project, but handy) # NDK (not needed for this project, but handy)
includeNDK = false; includeNDK = false;