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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user