diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 69cb7cf7..3c6d94dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 16df2bb5..bb5e0cb6 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 52ede98a..9b519f43 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -63,7 +63,7 @@ object AuthManager { private val _tokenFlow = MutableStateFlow(null) val tokenFlow: StateFlow = _tokenFlow.asStateFlow() - private val gson = com.google.gson.Gson() + private val gson = com.m57.hermescontrol.data.remote.OkHttpProvider.gson /** * Initialise the encrypted preferences. diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index b926873f..075ec4ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt new file mode 100644 index 00000000..714a0f05 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt @@ -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 = _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 + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 8e4f2b8a..e1ebad9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -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 safeApiCall( - retries: Int = 3, + retries: Int = 2, crossinline call: suspend () -> Response, ): NetworkResult { var lastException: IOException? = null @@ -68,8 +88,8 @@ suspend inline fun 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 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 safeApiCall( ), ) } - val backoff = 500L * (1 shl attempt) + val backoff = jitteredBackoff(500L * (1 shl attempt)) delay(backoff) } catch (e: Exception) { return NetworkResult.Failure( diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt new file mode 100644 index 00000000..1b7e6109 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt @@ -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() +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt new file mode 100644 index 00000000..a7076c97 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt @@ -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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 1f39d56f..c89b2845 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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( - extraBufferCapacity = 256, + extraBufferCapacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) /** Collect this from ViewModels to receive all parsed [WsEvent]s. */ val events: SharedFlow = 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.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() + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt index c24106c9..028f5e34 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -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 = _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 create(modelClass: Class): T { - return AuthLoginViewModel(app) as T - } + override fun create(modelClass: Class): T = AuthLoginViewModel(app) as T } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index c62ff99c..baf434a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 0d53482d..43d13fbd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 118e4ead..eeb3d643 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -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 ViewModel.safeLaunchLoad( + currentJob: Job? = null, crossinline apiCall: suspend () -> NetworkResult, 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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index a83d275e..dde2b9a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -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 = _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 create(modelClass: Class): T { - return ConnectViewModel(app) as T - } + override fun create(modelClass: Class): T = ConnectViewModel(app) as T } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index cd7fd8cc..b067d911 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 = _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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 1a1fe62f..25a1c20f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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 = _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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 5b2e6ec9..63dcd46c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -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 = _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) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt index 16eebdee..85bec6bb 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt @@ -15,7 +15,6 @@ class NetworkResultTest { val codesAndExpectedMessages = mapOf( 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.", 404 to "Not Found (HTTP 404): The requested resource could not be found.", 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 fun testMapHttpError_unknownCode() { val error = mapHttpError(418) @@ -44,6 +50,24 @@ class NetworkResultTest { 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 fun testSafeApiCall_successWithBody() = runBlocking { diff --git a/flake.nix b/flake.nix index 2602cb86..bef92fe9 100644 --- a/flake.nix +++ b/flake.nix @@ -30,10 +30,8 @@ platformVersions = [ "36" "35" "34" ]; # For the emulator - includeEmulator = true; - includeSystemImages = true; - systemImageTypes = [ "google_apis" ]; - abiVersions = [ "x86_64" ]; + includeEmulator = false; + includeSystemImages = false; # NDK (not needed for this project, but handy) includeNDK = false;