feat: optimize network stack (#404)

* feat: optimize network stack and connection management

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

* test: update mapping assertions for AuthExpired

* refactor: address PR review feedback on network stack

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

No files matched your search

@@ -48,7 +48,9 @@ data class AuthLoginUiState(
val errorMessage: String? = null,
)
class AuthLoginViewModel(private val app: Application) : ViewModel() {
class AuthLoginViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthLoginUiState())
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
@@ -57,11 +59,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
}
private val probeClient: OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) }
@@ -150,7 +148,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// Step 1: Check if dashboard is reachable via /api/status (always public)
val statusOk =
try {
val req = Request.Builder().url("$baseUrl/api/status").get().build()
val req =
Request
.Builder()
.url("$baseUrl/api/status")
.get()
.build()
val resp = probeClient.newCall(req).execute()
resp.isSuccessful
} catch (e: Exception) {
@@ -163,7 +166,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA
val needsBasicAuth =
try {
val req = Request.Builder().url(baseUrl).get().build()
val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute()
val code = resp.code
val location = resp.header("location", "")
@@ -178,7 +186,12 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
var extractedToken: String? = null
if (!needsBasicAuth) {
try {
val req = Request.Builder().url(baseUrl).get().build()
val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute()
val body = resp.body?.string() ?: ""
// Extract __HERMES_SESSION_TOKEN__ from the SPA HTML
@@ -231,12 +244,18 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
val token = connectTokenOnly(state.host, port, state.token)
if (token != null) ConnectResult(wsCredential = token) else null
}
DashboardAuthMode.BASIC_AUTH ->
DashboardAuthMode.BASIC_AUTH -> {
connectBasicAuth(state.host, port, state.username, state.password)
}
DashboardAuthMode.ALL -> {
connectBasicAuth(state.host, port, state.username, state.password)
}
null -> null
null -> {
null
}
}
}
@@ -279,6 +298,7 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
token
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
val msg =
when (val err = result.error) {
@@ -289,9 +309,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
else -> app.getString(R.string.connect_error_http_code, err.code)
}
}
is com.m57.hermescontrol.data.remote.NetworkError.AuthExpired -> {
app.getString(R.string.connect_error_401)
}
is com.m57.hermescontrol.data.remote.NetworkError.Connection -> {
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
}
is com.m57.hermescontrol.data.remote.NetworkError.Unknown -> {
app.getString(R.string.connect_error_connection_failed, err.cause.message ?: "")
}
@@ -331,14 +357,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
try {
// Step 1: Authenticate via the password login endpoint to get a session cookie
val loginClient =
OkHttpClient.Builder()
com.m57.hermescontrol.data.remote.OkHttpProvider.probe
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.followRedirects(false)
.build()
val loginReq =
Request.Builder()
Request
.Builder()
.url("$baseUrl/auth/password-login")
.header("Content-Type", "application/json")
.post(jsonBody.toRequestBody())
@@ -380,13 +407,15 @@ class AuthLoginViewModel(private val app: Application) : ViewModel() {
// Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient =
OkHttpClient.Builder()
com.m57.hermescontrol.data.remote.OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val ticketReq =
Request.Builder()
Request
.Builder()
.url("$baseUrl/api/auth/ws-ticket")
.header("Cookie", cookieHeader)
.post("{}".toRequestBody())
@@ -434,7 +463,5 @@ class AuthLoginViewModelFactory(
private val app: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return AuthLoginViewModel(app) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): T = AuthLoginViewModel(app) as T
}
@@ -4,11 +4,11 @@ import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -775,16 +775,14 @@ class ChatViewModel(
}
@Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? {
return try {
val gson = com.google.gson.Gson()
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try {
val json = gson.toJson(map)
gson.fromJson(json, CommandCatalog::class.java)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e)
null
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
@@ -1186,6 +1184,6 @@ class ChatViewModel(
}
companion object {
private val gson = Gson()
private val gson = OkHttpProvider.gson
}
}
@@ -216,8 +216,8 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
com.m57.hermescontrol.data.remote.OkHttpProvider
.gson
.toJson(it)
} ?: ""
val toolMessage =
@@ -264,8 +264,8 @@ object ChatWsEventReducer {
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
com.m57.hermescontrol.data.remote.OkHttpProvider
.gson
.toJson(it)
} ?: ""
val messages = state.messages.toMutableList()
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -12,17 +13,19 @@ interface ToastHost {
}
inline fun <T> ViewModel.safeLaunchLoad(
currentJob: Job? = null,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
) {
): Job {
if (currentJob?.isActive == true) return currentJob
onStart()
viewModelScope.launch {
return viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
when (result) {
is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message ?: "Unknown error")
is NetworkResult.Failure -> onError(result.error.message)
}
}
}
@@ -31,7 +31,9 @@ data class ConnectUiState(
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
)
class ConnectViewModel(private val app: Application) : ViewModel() {
class ConnectViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConnectUiState())
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
@@ -206,26 +208,44 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
}
}
is NetworkError.AuthExpired -> {
AuthManager.setToken(null)
if (AuthManager.getSelectedProfileId() != null) {
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
}
app.getString(R.string.connect_error_401)
}
is NetworkError.Connection -> {
val causeMessage = err.cause.message ?: ""
when {
causeMessage.contains(
"timeout",
true,
) -> app.getString(R.string.connect_error_timeout)
) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains(
"refused",
true,
) -> app.getString(R.string.connect_error_refused)
) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains(
"resolve",
true,
) -> app.getString(R.string.connect_error_resolve)
else ->
) -> {
app.getString(R.string.connect_error_resolve)
}
else -> {
String.format(
app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "",
)
}
}
}
@@ -235,20 +255,30 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
causeMessage.contains(
"timeout",
true,
) -> app.getString(R.string.connect_error_timeout)
) -> {
app.getString(R.string.connect_error_timeout)
}
causeMessage.contains(
"refused",
true,
) -> app.getString(R.string.connect_error_refused)
) -> {
app.getString(R.string.connect_error_refused)
}
causeMessage.contains(
"resolve",
true,
) -> app.getString(R.string.connect_error_resolve)
else ->
) -> {
app.getString(R.string.connect_error_resolve)
}
else -> {
String.format(
app.getString(R.string.connect_error_connection_failed),
err.cause.message ?: "",
)
}
}
}
}
@@ -359,7 +389,5 @@ class ConnectViewModelFactory(
private val app: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConnectViewModel(app) as T
}
override fun <T : ViewModel> create(modelClass: Class<T>): T = ConnectViewModel(app) as T
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,26 +24,32 @@ data class CronJobsUiState(
val toastMessage: String? = null,
)
class CronJobsViewModel : ViewModel(), ToastHost {
class CronJobsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadCronJobs() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: $errorMsg",
)
}
},
)
}
fun pauseCronJob(id: String) {
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +27,8 @@ class SessionsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
/** Page size sent to the server — matches the default the gateway uses. */
private companion object {
const val PAGE_SIZE = 20
@@ -33,37 +36,39 @@ class SessionsViewModel : ViewModel() {
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
fun loadSessions() {
safeLaunchLoad(
apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
sessions = data?.sessions.orEmpty(),
total = data?.total ?: 0,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
sessions = data?.sessions.orEmpty(),
total = data?.total ?: 0,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg",
)
}
},
)
}
/** Load the next page and append to the existing session list. */
@@ -92,6 +97,7 @@ class SessionsViewModel : ViewModel() {
)
}
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
_uiState.update {
it.copy(
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -29,26 +30,32 @@ data class SkillsUiState(
val saveContentSuccess: Boolean = false,
)
class SkillsViewModel : ViewModel(), ToastHost {
class SkillsViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun loadSkills() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: $errorMsg",
)
}
},
)
loadJob =
safeLaunchLoad(
currentJob = loadJob,
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: $errorMsg",
)
}
},
)
}
fun toggleSkill(skill: Skill) {