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:
1 parent
9c7bb8635f
commit
90c1e8dc5f
6 files changed
+131
-106
No files matched your search
+23
-16
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
@@ -31,22 +32,28 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
.url(baseUrl)
|
.url(baseUrl)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
client.newCall(request).execute().use { response ->
|
// execute() + body read on Dispatchers.IO: the body read touches
|
||||||
if (!response.isSuccessful) return null
|
// the socket on the CALLING thread otherwise — when that is main
|
||||||
// Cap the read — the SPA HTML can be large, and we only need the
|
// (loopback ticket mint from the kanban events connect), it throws
|
||||||
// injected token near the top of the document. Reading the whole
|
// NetworkOnMainThreadException (same bug class as requestWsTicket).
|
||||||
// body into a String risks OOM on low-end devices.
|
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||||
val html =
|
client.newCall(request).execute().use { response ->
|
||||||
response.body.source().use { source ->
|
if (!response.isSuccessful) return@runBlocking null
|
||||||
val buffer = Buffer()
|
// Cap the read — the SPA HTML can be large, and we only need the
|
||||||
source.read(buffer, MAX_BODY_BYTES)
|
// injected token near the top of the document. Reading the whole
|
||||||
buffer.readUtf8()
|
// body into a String risks OOM on low-end devices.
|
||||||
}
|
val html =
|
||||||
tokenPattern
|
response.body.source().use { source ->
|
||||||
.find(html)
|
val buffer = Buffer()
|
||||||
?.groupValues
|
source.read(buffer, MAX_BODY_BYTES)
|
||||||
?.getOrNull(1)
|
buffer.readUtf8()
|
||||||
?.takeIf { it.isNotBlank() }
|
}
|
||||||
|
tokenPattern
|
||||||
|
.find(html)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -450,28 +450,34 @@ object HermesWsClient {
|
|||||||
.post("{}".toRequestBody())
|
.post("{}".toRequestBody())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Run network call on Dispatchers.IO to avoid NetworkOnMainThreadException
|
// Run the ENTIRE call on Dispatchers.IO — execute() already hops,
|
||||||
val response =
|
// 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) {
|
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||||
client.newCall(request).execute()
|
client.newCall(request).execute().use { resp ->
|
||||||
|
resp.code to resp.body?.string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.use {
|
if (body != null && code in 200..299) {
|
||||||
if (response.isSuccessful) {
|
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||||
val body = response.body.string()
|
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
if (!ticket.isNullOrBlank()) {
|
||||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
return TicketRequestResult(ticket, null)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} 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, exception = true)
|
||||||
}
|
}
|
||||||
return TicketRequestResult(null, null)
|
return TicketRequestResult(null, null)
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ class KanbanViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectBoard(board: KanbanBoard) {
|
fun selectBoard(board: KanbanBoard) {
|
||||||
|
val current = _uiState.value.selectedBoard
|
||||||
|
if (current?.id == board.id) {
|
||||||
|
// Already-active board: reload its data and (re)connect the events
|
||||||
|
// stream WITHOUT the /switch round-trip — refresh and createTask
|
||||||
|
// re-select the same board and must not POST a redundant switch.
|
||||||
|
viewModelScope.launch { loadBoardIntoState(board) }
|
||||||
|
connectEvents(board)
|
||||||
|
return
|
||||||
|
}
|
||||||
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val switchResult =
|
val switchResult =
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -121,86 +120,82 @@ fun PluginsScreen(
|
|||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.screen_plugins)) },
|
title = { Text(stringResource(R.string.screen_plugins)) },
|
||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = { viewModel.loadPlugins() },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
PullToRefreshBox(
|
when {
|
||||||
isRefreshing = state.isLoading,
|
state.isLoading && state.plugins.isEmpty() -> {
|
||||||
onRefresh = { viewModel.loadPlugins() },
|
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||||
modifier = Modifier.fillMaxSize(),
|
}
|
||||||
) {
|
|
||||||
when {
|
|
||||||
state.isLoading && state.plugins.isEmpty() -> {
|
|
||||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
|
||||||
}
|
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
onRetry = { viewModel.loadPlugins() },
|
onRetry = { viewModel.loadPlugins() },
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.plugins_empty_title),
|
title = stringResource(R.string.plugins_empty_title),
|
||||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||||
onAction = { viewModel.loadPlugins() },
|
onAction = { viewModel.loadPlugins() },
|
||||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = listContentPadding,
|
contentPadding = listContentPadding,
|
||||||
verticalArrangement = listItemSpacing,
|
verticalArrangement = listItemSpacing,
|
||||||
) {
|
) {
|
||||||
// Provider selection section
|
// Provider selection section
|
||||||
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
||||||
item(key = "providers") {
|
item(key = "providers") {
|
||||||
ProviderSelectionSection(state, viewModel)
|
ProviderSelectionSection(state, viewModel)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Install section
|
// Install section
|
||||||
item(key = "install") {
|
item(key = "install") {
|
||||||
InstallSection(state, viewModel)
|
InstallSection(state, viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search bar
|
// Search bar
|
||||||
item(key = "search") {
|
item(key = "search") {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { query = it },
|
onQueryChange = { query = it },
|
||||||
placeholder = "Search plugins...",
|
placeholder = "Search plugins...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin list
|
||||||
|
items(filteredPlugins, key = { it.name }) { plugin ->
|
||||||
|
PluginCard(
|
||||||
|
plugin = plugin,
|
||||||
|
state = state,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onClick = { showDetail = plugin },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphan dashboard plugins section
|
||||||
|
if (state.orphanPlugins.isNotEmpty()) {
|
||||||
|
item(key = "orphan-header") {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.plugins_orphan_heading),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||||
// Plugin list
|
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
||||||
items(filteredPlugins, key = { it.name }) { plugin ->
|
|
||||||
PluginCard(
|
|
||||||
plugin = plugin,
|
|
||||||
state = state,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onClick = { showDetail = plugin },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orphan dashboard plugins section
|
|
||||||
if (state.orphanPlugins.isNotEmpty()) {
|
|
||||||
item(key = "orphan-header") {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.plugins_orphan_heading),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
|
||||||
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.material.icons.filled.CloudDownload
|
|||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Extension
|
import androidx.compose.material.icons.filled.Extension
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Shield
|
import androidx.compose.material.icons.filled.Shield
|
||||||
@@ -113,11 +112,19 @@ fun SkillsScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = { Text(stringResource(R.string.skills_screen_title)) },
|
title = { Text(stringResource(R.string.skills_screen_title)) },
|
||||||
navigationIcon = NavIcon.Menu(onOpen = onOpenDrawer),
|
navigationIcon = NavIcon.Menu(onOpen = onOpenDrawer),
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = {
|
||||||
|
if (state.viewMode == SkillsViewMode.INSTALLED) {
|
||||||
|
viewModel.loadSkills()
|
||||||
|
} else {
|
||||||
|
viewModel.loadHubSources()
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (state.viewMode == SkillsViewMode.INSTALLED) {
|
if (state.viewMode == SkillsViewMode.INSTALLED) {
|
||||||
IconButton(onClick = { viewModel.updateSkillsFromHub() }) {
|
IconButton(onClick = { viewModel.updateSkillsFromHub() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Refresh,
|
imageVector = Icons.Filled.CloudDownload,
|
||||||
contentDescription = stringResource(R.string.content_desc_update_skills_hub),
|
contentDescription = stringResource(R.string.content_desc_update_skills_hub),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class HermesWsClientTest {
|
|||||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
|
||||||
mockWebServer = MockWebServer()
|
mockWebServer = MockWebServer()
|
||||||
|
|||||||
Reference in new issue
Block a user