fix: tab reloads actually reload — kanban switch spam, plugins/skills refresh, ws mint (#897)

- ws: ticket-mint response body was read on the calling (main) thread —
  the socket read threw NetworkOnMainThreadException, the mint failed,
  and the kanban events stream never connected (board stuck Offline).
  execute() + body read now run entirely on Dispatchers.IO; same fix in
  DashboardSessionTokenRefresher.fetch (loopback mint path).
- kanban: re-selecting the already-active board reloads data and
  reconnects events WITHOUT the redundant POST /boards/{id}/switch that
  refresh fired on every tap.
- plugins: top-bar Refresh + pull-to-refresh wired through HermesScaffold
  (previously no reload control at all).
- skills: Refresh icon now does a plain GET reload; the heavy hub-update
  POST moved to the cloud icon so refresh is no longer disguised as update.
- test: mock the 3-arg Log.w overload (catch now logs the full stack).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-13 14:27:36 +03:00
1 parent 9c7bb8635f
commit 90c1e8dc5f
6 files changed
+131 -106

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Buffer
@@ -31,22 +32,28 @@ internal object DashboardSessionTokenRefresher {
.url(baseUrl)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
// execute() + body read on Dispatchers.IO: the body read touches
// the socket on the CALLING thread otherwise — when that is main
// (loopback ticket mint from the kanban events connect), it throws
// NetworkOnMainThreadException (same bug class as requestWsTicket).
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@runBlocking null
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
}
} catch (_: Exception) {
null
@@ -450,28 +450,34 @@ object HermesWsClient {
.post("{}".toRequestBody())
.build()
// Run network call on Dispatchers.IO to avoid NetworkOnMainThreadException
val response =
// Run the ENTIRE call on Dispatchers.IO — execute() already hops,
// but ResponseBody.string() reads the socket on the CALLING
// thread. When the caller is main (kanban events connect), that
// read throws NetworkOnMainThreadException and the mint fails.
val (code, body) =
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
client.newCall(request).execute()
client.newCall(request).execute().use { resp ->
resp.code to resp.body?.string()
}
}
response.use {
if (response.isSuccessful) {
val body = response.body.string()
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
if (!ticket.isNullOrBlank()) {
return TicketRequestResult(ticket, null)
}
Log.w(TAG, "WS ticket mint failed: response body did not contain ticket")
} else {
Log.w(TAG, "WS ticket mint failed: HTTP ${response.code}")
return TicketRequestResult(null, response.code)
if (body != null && code in 200..299) {
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
if (!ticket.isNullOrBlank()) {
return TicketRequestResult(ticket, null)
}
Log.w(TAG, "WS ticket mint failed: response body did not contain ticket")
} else {
Log.w(TAG, "WS ticket mint failed: HTTP $code")
return TicketRequestResult(null, code)
}
} catch (e: Exception) {
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}")
// Include the stack: the exception class alone (e.g.
// NetworkOnMainThreadException) can't show WHICH caller on what
// thread tripped the mint (main-scope kanban events connect vs
// the WS reconnect path).
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e)
return TicketRequestResult(null, null, exception = true)
}
return TicketRequestResult(null, null)