chore(release): promote dev to main for v1.29.0 (#1201)

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
m57andhy4ri authored and GitHub committed 2026-09-20 00:57:46 +03:00
1 parent c299dd01b2
commit 67097c4d1e
179 files changed
+14930 -1737

No files matched your search

+1 -1
View File
@@ -162,7 +162,7 @@ jobs:
needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
+15
View File
@@ -91,6 +91,21 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
_Note: For release builds, ensure keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) are configured, or let the GitHub Actions release workflow handle it on tag push (`v*`)._
### Nix emulator
The Nix development shell enables physical keyboard input in an existing
`hermes_dev` AVD configuration. It respects `ANDROID_AVD_HOME` and
`ANDROID_USER_HOME`, defaulting to `~/.android/avd`.
Close any running emulator, then cold boot once to apply the setting:
```bash
nix develop --command emulator -avd hermes_dev -no-snapshot-load
```
Subsequent launches can omit `-no-snapshot-load`. Create the `hermes_dev` AVD
first if it does not exist; the shell does not create one.
---
## Authentication
+5
View File
@@ -176,6 +176,11 @@ dependencies {
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
// Media3 (ExoPlayer)
implementation(libs.media3.exoplayer)
implementation(libs.media3.ui)
implementation(libs.media3.datasource.okhttp)
// Encrypted storage
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore)
@@ -0,0 +1,115 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "4f44d92f9b8db2b02a1b156b37d64651",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reasoningText",
"columnName": "reasoning_text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolCallId",
"columnName": "tool_call_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayKind",
"columnName": "display_kind",
"affinity": "TEXT"
},
{
"fieldPath": "tokenCount",
"columnName": "token_count",
"affinity": "INTEGER"
},
{
"fieldPath": "tps",
"columnName": "tps",
"affinity": "REAL"
},
{
"fieldPath": "completionId",
"columnName": "completion_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_timestamp",
"unique": false,
"columnNames": [
"session_id",
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4f44d92f9b8db2b02a1b156b37d64651')"
]
}
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,77 @@
package com.m57.hermescontrol.data.ws
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Issue #1163: execute the wire parser on ART, without backend credentials or model calls. */
@RunWith(AndroidJUnit4::class)
class StreamingEventDeviceTest {
@get:Rule
val compose = createComposeRule()
@Test
fun streamingBurstPreservesEveryChunkAndSessionOnDevice() {
val types = listOf("message.token", "message.delta", "thinking.delta", "reasoning.delta")
for (type in types) {
val actual = StringBuilder()
val expected = StringBuilder()
repeat(1000) { seq ->
val frame =
"""{"method":"event","params":{"type":"$type","seq":$seq,""" +
""""payload":{"session_id":"device-test","text":"chunk-$seq ../ \uD83C\uDF19\n",""" +
""""extra":{"nested":[{"text":"ignored","path":"../ignored"}]}}}}"""
val response = Json.decodeFromString<JsonRpcResponse>(frame)
val event = EventParser.parse(response, frame)
val (token, session) =
when (event) {
is WsEvent.MessageToken -> event.token to event.sessionId
is WsEvent.ThinkingDelta -> event.token to event.sessionId
is WsEvent.ReasoningDelta -> event.token to event.sessionId
else -> error("Unexpected streaming event: $event")
}
assertEquals("device-test", session)
actual.append(token)
expected.append("chunk-$seq ../ 🌙\n")
}
assertEquals(type, expected.toString(), actual.toString())
}
}
@Test
fun reasoningFastPathAvoidsContainerTraversalAndRendersOnDevice() {
val fields =
mapOf<String, JsonElement>(
"type" to JsonPrimitive("reasoning.delta"),
"payload" to JsonObject(mapOf("text" to JsonPrimitive("Device streaming verified"))),
)
val params =
JsonObject(
object : Map<String, JsonElement> by fields {
override val entries: Set<Map.Entry<String, JsonElement>>
get() = error("Streaming parser traversed the params tree")
},
)
val event = EventParser.parse(JsonRpcResponse(method = "event", params = params)) as WsEvent.ReasoningDelta
compose.setContent {
HermesControlTheme {
ReasoningCard(reasoningText = event.token)
}
}
compose.onNodeWithTag("reasoning_card").performClick()
compose.onNodeWithText("Device streaming verified").assertIsDisplayed()
}
}
@@ -0,0 +1,500 @@
package com.m57.hermescontrol.ui.chat
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ChatNotificationService
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ChatReadNotificationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var context: Context
private lateinit var notificationManager: NotificationManager
private val testChannelId = "test_chat_channel"
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
InstrumentationRegistry
.getInstrumentation()
.uiAutomation
.grantRuntimePermission(
context.packageName,
android.Manifest.permission.POST_NOTIFICATIONS,
)
}
val channel =
NotificationChannel(
testChannelId,
"Test Chat Notifications",
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)
notificationManager.cancelAll()
ReplyNotificationTracker.resetForTest()
}
@After
fun tearDown() {
notificationManager.cancelAll()
ReplyNotificationTracker.resetForTest()
}
private fun awaitNotification(
timeoutMs: Long = 3000L,
predicate: (Array<StatusBarNotification>) -> Boolean,
): Boolean {
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMs) {
if (predicate(notificationManager.activeNotifications)) return true
Thread.sleep(50)
}
return predicate(notificationManager.activeNotifications)
}
private fun postRealNotification(
kind: String,
scopeId: String = "default",
sessionId: String = "session-1",
completionId: String = "comp-1",
textSnippet: String = "Here is the reply",
serverMessageId: Int? = null,
): Long {
val gen =
if (kind == ReplyNotificationTracker.KIND_REPLY) {
ReplyNotificationTracker.registerPendingReply(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
textSnippet = textSnippet,
)
} else {
ReplyNotificationTracker.nextGeneration()
}
val notification =
NotificationCompat
.Builder(context, testChannelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Agent")
.setContentText(textSnippet)
.addExtras(
Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, kind)
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, textSnippet)
putLong(ReplyNotificationTracker.EXTRA_GENERATION, gen)
serverMessageId?.let {
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
}
},
).build()
when (kind) {
ReplyNotificationTracker.KIND_REPLY -> {
ReplyNotificationTracker.postReplyNotification(context, notification, gen)
}
ReplyNotificationTracker.KIND_ACTION -> {
ReplyNotificationTracker.postActionNotification(context, notification)
}
ReplyNotificationTracker.KIND_REPLIED -> {
ReplyNotificationTracker.postRepliedNotification(context, notification)
}
}
val active = awaitNotification { sbns -> sbns.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue("Notification ID 2 must be active in system after posting", active)
return gen
}
@Test
fun chatLifecycleEffects_dismissesRealSystemNotification_whenAssistantMessageDisplayed() {
val sessionId = "session-read-test"
val completionId = "comp-12345"
val replyText = "Here is the completed response."
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = replyText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
val dismissed =
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue(
"Notification ID 2 must be dismissed from real system notifications when message is viewed",
dismissed,
)
}
/**
* The mobile-produced durable row id must survive in the real posted
* notification; after a process death it is the only thing that can name the
* row whose visibility dismisses this notification.
*/
@Test
fun serverRowIdSurvivesProcessDeathThroughRealNotificationExtras() {
val sessionId = "session-exact-row-test"
val replyText = "Done"
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = "comp-exact-row",
textSnippet = replyText,
serverMessageId = 52,
)
// Simulate process death: nothing in memory, only the posted extras.
ReplyNotificationTracker.resetForTest()
val recovered = ReplyNotificationTracker.getActiveTarget(context)
assertEquals(
"Recovered target must carry the durable row id from the notification extras",
52,
recovered?.serverMessageId,
)
val history =
listOf(
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
)
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
val dismissed =
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue(
"Viewing the exact hydrated row must dismiss the real system notification",
dismissed,
)
}
@Test
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forActionRequest() {
val sessionId = "session-action-test"
val completionId = "comp-action"
val promptText = "Approval required"
postRealNotification(
kind = ReplyNotificationTracker.KIND_ACTION,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = promptText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = promptText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Action notifications must NOT be auto-dismissed when viewing chat",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
@Test
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forRepliedNotification() {
val sessionId = "session-replied-test"
val completionId = "comp-replied"
val replyStatusText = "Replied: done"
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLIED,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = replyStatusText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyStatusText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Replied status notifications must NOT be auto-dismissed when viewing chat",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
@Test
fun chatLifecycleEffects_doesNotDismissNotification_forDifferentSession() {
val sessionIdWithNotif = "session-with-notif"
val differentSessionId = "other-session"
val completionId = "comp-12345"
val replyText = "Here is the completed response."
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionIdWithNotif,
completionId = completionId,
textSnippet = replyText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = differentSessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = differentSessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Notification ID 2 must remain active for a different session",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
}
@@ -0,0 +1,229 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentUris
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class MediaViewerDeviceTest {
@get:Rule
val compose = createComposeRule()
private fun open(
asset: String,
mime: String,
) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val file = File(instrumentation.targetContext.cacheDir, asset)
instrumentation.context.assets
.open(asset)
.use { input -> file.outputStream().use(input::copyTo) }
compose.setContent {
var visible by remember { mutableStateOf(true) }
HermesControlTheme {
if (visible) {
MediaViewerDialog(file.toURI().toString(), { visible = false }, asset, mime)
}
}
}
}
private fun awaitDescription(resource: Int) {
val text = InstrumentationRegistry.getInstrumentation().targetContext.getString(resource)
compose.waitUntil(15_000) {
compose.onAllNodesWithContentDescription(text).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitTag(tag: String) {
compose.waitUntil(15_000) {
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun checkPlayback(
asset: String,
mime: String,
) {
open(asset, mime)
awaitDescription(R.string.media_player_pause)
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_play)
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.takeScreenshot().let { bitmap ->
File(instrumentation.targetContext.cacheDir, "$asset.png").outputStream().use {
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)
}
bitmap.recycle()
}
compose.onNodeWithTag("media_seek_slider").performTouchInput {
swipe(start = center.copy(x = width * 0.2f), end = center.copy(x = width * 0.9f))
}
compose.waitUntil(5_000) {
val progress =
compose
.onNodeWithTag("media_seek_slider")
.fetchSemanticsNode()
.config[SemanticsProperties.ProgressBarRangeInfo]
progress.current > progress.range.endInclusive * 0.75f
}
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_replay)
awaitTag("media_play_pause_button")
compose.onNodeWithTag("media_play_pause_button").performClick()
awaitDescription(R.string.media_player_pause)
awaitTag("media_close_button")
compose.onNodeWithTag("media_close_button").performClick()
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
}
}
@Test
fun audioPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-tone.wav", "audio/wav")
@Test
fun videoPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-video.mp4", "video/mp4")
@Test
fun saveButtonStreamsToDownloadsAndVerifiesMediaStoreItem() {
open("player-tone.wav", "audio/wav")
awaitTag("media_save_button")
compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
val instrumentation = InstrumentationRegistry.getInstrumentation()
val resolver = instrumentation.targetContext.contentResolver
var savedUri: Uri? = null
compose.waitUntil(15_000) {
val cursor =
resolver.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf("player-tone.wav"),
"${MediaStore.MediaColumns._ID} DESC",
)
cursor?.use { c ->
if (c.moveToFirst()) {
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
val name = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
val mime = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE))
assertEquals("player-tone.wav", name)
assertTrue(mime == "audio/wav" || mime == "audio/x-wav")
true
} else {
false
}
} ?: false
}
try {
assertNotNull(savedUri)
val expectedBytes =
instrumentation.context.assets
.open("player-tone.wav")
.use { it.readBytes() }
val actualBytes = resolver.openInputStream(savedUri!!)?.use { it.readBytes() }
assertArrayEquals(expectedBytes, actualBytes)
} finally {
savedUri?.let { resolver.delete(it, null, null) }
}
compose.onNodeWithTag("media_close_button").performClick()
}
@Test
fun shareMediaBuildsValidIntentAndReadableStream() =
runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val file = File(context.cacheDir, "player-tone.wav")
instrumentation.context.assets.open("player-tone.wav").use { input ->
file.outputStream().use(input::copyTo)
}
val intent =
MediaExportHelper.shareMedia(
context = context,
uri = file.toURI().toString(),
fallbackMime = "audio/wav",
displayName = "player-tone.wav",
)
assertNotNull(intent)
assertEquals(Intent.ACTION_SEND, intent!!.action)
assertEquals("audio/wav", intent.type)
assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0)
@Suppress("DEPRECATION")
val streamUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
assertNotNull(streamUri)
val expectedSize = file.length()
val actualSize =
context.contentResolver.openInputStream(streamUri!!)?.use { stream ->
var count = 0L
val buf = ByteArray(8192)
while (true) {
val r = stream.read(buf)
if (r < 0) break
count += r
}
count
}
assertEquals(expectedSize, actualSize)
}
@Test
fun invalidSourceShowsRetryAndCanClose() {
compose.setContent {
HermesControlTheme {
MediaViewerDialog("file:///missing-media-test.mp3", {}, mimeType = "audio/mpeg")
}
}
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("media_retry_button").assertIsDisplayed().performClick()
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("media_close_button").assertIsDisplayed()
}
}
@@ -59,6 +59,7 @@ class ComposerInteractionTest {
composerWidth: Dp? = null,
modelAfterTap: String? = null,
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
showModelProvider: Boolean = false,
) {
composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) }
@@ -81,6 +82,7 @@ class ComposerInteractionTest {
isListening = listening,
isAgentTyping = false,
isConnected = true,
isSessionReady = true,
commandCatalog = CommandCatalog(),
currentSessionModel = currentModel,
reasoningLevel = reasoningLevel,
@@ -92,6 +94,7 @@ class ComposerInteractionTest {
onCameraTap = { cameraTaps++ },
onImageTap = { photosTaps++ },
onFileTap = { fileTaps++ },
showModelProvider = showModelProvider,
)
}
if (composerWidth == null) {
@@ -205,9 +208,9 @@ class ComposerInteractionTest {
}
@Test
fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() {
fun longModelName_remainsScrollableAndDoesNotTriggerPicker() {
val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters"
setComposer(model = longModel, composerWidth = 280.dp)
setComposer(model = longModel, composerWidth = 280.dp, showModelProvider = true)
composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed()
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
@@ -251,7 +254,7 @@ class ComposerInteractionTest {
fun changingModel_resetsScrolledLabelToTheStart() {
val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters"
val secondModel = "anthropic/another-extremely-long-model-name-preview-2026-with-extra-characters"
setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp)
setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp, showModelProvider = true)
val firstText = composeTestRule.onNodeWithText(firstModel)
val leftBefore = firstText.getUnclippedBoundsInRoot().left
@@ -343,4 +346,29 @@ class ComposerInteractionTest {
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
@Test
fun defaultComposer_showsOnlyModelNameWithoutProvider() {
setComposer(model = "openai/gpt-5.5", showModelProvider = false)
composeTestRule.onNodeWithText("gpt-5.5").assertIsDisplayed()
composeTestRule.onNodeWithText("openai/gpt-5.5").assertDoesNotExist()
}
@Test
fun enabledComposer_showsProviderAndModelName() {
setComposer(model = "openai/gpt-5.5", showModelProvider = true)
composeTestRule.onNodeWithText("openai/gpt-5.5").assertIsDisplayed()
composeTestRule.onNodeWithText("gpt-5.5").assertDoesNotExist()
}
@Test
fun defaultComposer_showsOnlyFinalModelSegmentForNestedProvider() {
setComposer(model = "openrouter/openai/gpt-5", showModelProvider = false)
composeTestRule.onNodeWithText("gpt-5").assertIsDisplayed()
composeTestRule.onNodeWithText("openai/gpt-5").assertDoesNotExist()
composeTestRule.onNodeWithText("openrouter/openai/gpt-5").assertDoesNotExist()
}
}
@@ -0,0 +1,55 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.input.TextFieldValue
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ComposerReadinessTest {
@get:Rule
val compose = createComposeRule()
@Test
fun draftRemainsEditableAndSendStaysDisabledUntilReady() {
val ready = mutableStateOf(false)
var sends = 0
compose.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = { sends++ },
onMicTap = {},
isListening = false,
isAgentTyping = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = ready.value,
)
}
compose.onNodeWithTag("chat_input").performTextInput("held draft")
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
compose.onNodeWithTag("chat_session_preparing").assertExists()
compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) }
compose.runOnIdle { ready.value = false }
compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
}
}
@@ -0,0 +1,47 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.isNewerVersion
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppUpdatePrereleaseTest {
@get:Rule
val compose = createComposeRule()
@Test
fun stableReleaseShowsDialogForHyphenRc() = assertStablePrompt("1.25.0-rc.1")
@Test
fun stableReleaseShowsDialogForDotRc() = assertStablePrompt("1.25.rc.1")
private fun assertStablePrompt(installed: String) {
val latest = "v1.25"
assertTrue(isNewerVersion(latest, installed))
assertFalse(isNewerVersion(latest, "1.25"))
compose.setContent {
HermesControlTheme {
if (isNewerVersion(latest, installed)) {
AppUpdateDialog(
state = AppUpdateState.UpdateAvailable(latest, "https://example.com/test.apk", 1024L),
onDismiss = {},
onStartUpdate = {},
onCancelDownload = {},
onNeverAskAgain = {},
onOpenSettings = {},
)
}
}
}
compose.onNodeWithText(latest, substring = true).assertIsDisplayed()
}
}
@@ -0,0 +1,62 @@
package com.m57.hermescontrol.ui.sessions
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SearchResultCardTest {
@get:Rule
val compose = createComposeRule()
private var clicks = 0
private fun show(title: String?) {
compose.setContent {
HermesControlTheme {
SearchResultCard(
session = SessionInfo(id = "fixture", title = title, preview = ">>>launch<<< notes excerpt"),
query = "launch notes",
isSelecting = false,
isSelected = false,
isDeleting = false,
highlightBackground = Color.Unspecified,
highlightForeground = Color.Unspecified,
onCardClick = { clicks++ },
onToggleSelection = {},
onSelect = {},
onRename = {},
onDelete = {},
)
}
}
}
@Test
fun titleAndExcerptAreDistinctAndTapOpensResult() {
show("Launch checklist")
compose.onNodeWithTag("search_title_fixture", useUnmergedTree = true).assertIsDisplayed()
compose.onNodeWithText("Launch checklist", useUnmergedTree = true).assertIsDisplayed()
compose.onNodeWithText("launch notes excerpt", useUnmergedTree = true).assertIsDisplayed()
compose.onNodeWithTag("session_card_fixture").performClick()
compose.runOnIdle { assertEquals(1, clicks) }
}
@Test
fun legacyResultDoesNotInventATitle() {
show(null)
compose.onNodeWithTag("search_title_fixture", useUnmergedTree = true).assertDoesNotExist()
compose.onNodeWithText("launch notes excerpt", useUnmergedTree = true).assertIsDisplayed()
}
}
@@ -0,0 +1,97 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.update.AppUpdateState
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The opt-in release-candidate switch on the About tab: stable-only default,
* one callback per tap, and no channel flip while an APK is in flight.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class AboutSectionReleaseCandidateTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun stableOnlyByDefault_tapReportsOptIn() {
var reported: Boolean? = null
composeTestRule.setContent {
AboutSection(
updateState = AppUpdateState.Idle,
checkReleaseCandidateUpdates = false,
onCheckReleaseCandidateUpdatesChange = { reported = it },
)
}
composeTestRule.onNodeWithText(RC_TITLE).assertIsDisplayed()
composeTestRule.onNode(isToggleable()).assertIsOff()
composeTestRule.onNode(isToggleable()).performClick()
assertEquals(true, reported)
}
@Test
fun optedIn_switchShowsChecked() {
composeTestRule.setContent {
AboutSection(
updateState = AppUpdateState.Idle,
checkReleaseCandidateUpdates = true,
)
}
composeTestRule.onNode(isToggleable()).assertIsOn()
}
@Test
fun downloading_disablesTheChannelSwitch() {
composeTestRule.setContent {
AboutSection(
updateState = AppUpdateState.Downloading(0.5f),
checkReleaseCandidateUpdates = false,
)
}
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
}
@Test
fun installing_disablesTheChannelSwitch() {
composeTestRule.setContent {
AboutSection(
updateState = AppUpdateState.Installing("v1.25.0-rc.3"),
checkReleaseCandidateUpdates = true,
)
}
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
}
@Test
fun idle_keepsTheChannelSwitchEnabled() {
composeTestRule.setContent {
AboutSection(updateState = AppUpdateState.UpToDate("v1.24.2"))
}
composeTestRule.onNode(isToggleable()).assertIsEnabled()
}
private companion object {
const val RC_TITLE = "Check release candidate updates"
}
}
@@ -10,28 +10,41 @@ import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class HermesControlApp :
Application(),
SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
AuthManager.init(this)
// Issue #478: guarantee a "Default" profile is always selected so there is no
// separate standalone/default code path anywhere in the app.
AuthManager.ensureDefaultProfile()
NetworkMonitor.init(this)
// Issue #537 follow-up (A): preload analytics in the background after launch
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
SessionListCacheStore.init(this)
// Reply-notification turn boundaries survive process death (armed before
// each mobile prompt, consumed by the matching completion).
TurnCorrelationTracker.attach(this)
appScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
// Issue #537 follow-up (A): preload analytics in the background after launch
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this@HermesControlApp)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
}
}
override fun newImageLoader(context: PlatformContext): ImageLoader =
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
companion object {
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION"
}
private var baseContextLanguageCode: String = LocaleContextWrapper.SYSTEM_LANGUAGE
/**
* Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
val code =
runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
baseContextLanguageCode = code
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
}
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
consumeNotificationIntent(intent)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AuthManager.initializationState.collect { state ->
if (state == AuthManager.InitializationState.Ready) {
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
}
}
}
enableEdgeToEdge()
setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
@@ -65,7 +89,35 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainNavigation()
val initState by AuthManager.initializationState.collectAsStateWithLifecycle()
when (initState) {
AuthManager.InitializationState.Loading -> {
LoadingState()
}
AuthManager.InitializationState.Ready -> {
val savedLanguage = AuthManager.getAppLanguage()
if (savedLanguage != baseContextLanguageCode) {
// The next activity attaches with loaded preferences. Keep navigation
// gated across recompositions until that recreation completes (#1171).
LaunchedEffect(savedLanguage) {
recreate()
}
LoadingState()
} else {
MainNavigation()
}
}
AuthManager.InitializationState.Failed -> {
ErrorState(
message = stringResource(R.string.auth_init_failed),
onRetry = {
AuthManager.init(applicationContext)
},
)
}
}
}
}
}
@@ -82,9 +134,6 @@ class MainActivity : ComponentActivity() {
NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this)
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
override fun onPause() {
@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
class ServerStore(
class ServerStore private constructor(
private val dataStore: DataStore<ServerStoreState>,
private val scope: CoroutineScope,
initial: ServerStoreState,
) {
private val _stateFlow: MutableStateFlow<ServerStoreState>
val stateFlow: StateFlow<ServerStoreState>
private val _stateFlow = MutableStateFlow(initial)
val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
init {
val initial =
runBlocking(Dispatchers.IO) {
try {
dataStore.data.first().selfHealed()
} catch (e: Exception) {
ServerStoreSerializer.defaultValue
}
}
_stateFlow = MutableStateFlow(initial)
stateFlow = _stateFlow.asStateFlow()
companion object {
// Do not expose a default snapshot before disk/migrations finish (issue #1171).
suspend fun create(
dataStore: DataStore<ServerStoreState>,
scope: CoroutineScope,
): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
}
fun getLatestState(): ServerStoreState = _stateFlow.value
@@ -28,6 +28,11 @@ data class ServerStoreState(
val showUserMessageTokens: Boolean = true,
val showAssistantMessageTokens: Boolean = true,
val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
val keepConnectedInBackground: Boolean = false,
// Opt-in update channel: when true the in-app update check also considers
// pre-release release-candidate GitHub releases, not just stable ones.
val checkReleaseCandidateUpdates: Boolean = false,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
@@ -20,9 +20,12 @@ import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
/**
* Singleton that manages encrypted storage of the Hermes dashboard token
@@ -124,37 +127,83 @@ object AuthManager {
initialValue = null,
)
private val _authGenerationFlow = MutableStateFlow(0L)
val authGenerationFlow: StateFlow<Long> = _authGenerationFlow.asStateFlow()
fun invalidateAuthGeneration() {
_authGenerationFlow.value = _authGenerationFlow.value + 1
}
/**
* Canonical observable data-scope identity covering connection profile, base URL,
* active server-side Hermes profile, and in-memory auth generation.
*/
val dataScopeFlow: StateFlow<DataScope?> =
combine(
selectedProfileFlow,
baseUrlFlow,
activeProfileId,
authGenerationFlow,
) { selectedProfile, baseUrl, activeProfile, authGen ->
val cleanUrl = baseUrl.trimEnd('/')
if (cleanUrl.isBlank()) {
null
} else {
DataScope(
connectionProfileId = selectedProfile ?: DEFAULT_PROFILE_ID,
baseUrl = cleanUrl,
activeProfileId = activeProfile?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = authGen,
)
}
}.stateIn(
scope = contextScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
*/
enum class InitializationState { Loading, Ready, Failed }
private val _initializationState = MutableStateFlow(InitializationState.Loading)
val initializationState: StateFlow<InitializationState> = _initializationState.asStateFlow()
@Volatile
private var initialization: Deferred<Unit>? = null
@Volatile
private var readyPrefs: SharedPreferences? = null
private var cachedDatabasePassword: ByteArray? = null
// Issue #1171: publish readiness only after persisted profiles and credentials are loaded.
fun init(context: Context) {
if (_serverStore != null) return
synchronized(this) {
if (_serverStore != null) return
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) {
context.filesDir.resolve("server_store.json")
}
val scope = CoroutineScope(Dispatchers.IO)
if (initialization != null && _initializationState.value != InitializationState.Failed) return
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
appScope?.cancel()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
appScope = scope
val store = ServerStore(dataStore, scope)
_serverStore = store
_selectedProfileFlow.value = store.getLatestState().selectedProfileId
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
if (prefsDeferred == null) {
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
_initializationState.value = InitializationState.Loading
readyPrefs = null
val prefs = CompletableDeferred<SharedPreferences>(scope.coroutineContext[kotlinx.coroutines.Job])
prefsDeferred = prefs
initialization =
scope.async {
try {
previousJob?.join()
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations = listOf(ServerStoreMigration(context), ServerUrlMigration()),
scope = scope,
) { context.filesDir.resolve("server_store.json") }
val store = ServerStore.create(dataStore, scope)
_serverStore = store
ensureDefaultProfile()
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val p =
EncryptedSharedPreferences.create(
@@ -165,52 +214,60 @@ object AuthManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
migrateLegacyDefaultIfNeeded(p)
_tokenFlow.value = getTokenInternal(p)
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
p
synchronized(this@AuthManager) {
readyPrefs = p
cachedToken = getTokenInternal(p)
tokenInitialized = true
_tokenFlow.value = cachedToken
_activeProfileId.value =
p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
}
prefs.complete(p)
val state = store.getLatestState()
_selectedProfileFlow.value = state.selectedProfileId
_baseUrlFlow.value = state.resolvedBaseUrl
val profileId = normalizedProfileId(state.selectedProfileId)
CookieManager.initialize(context, prefs, profileId)
CookieManager.useStore(profileId)
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
scope.launch {
store.stateFlow.collect { latest ->
_themePreferenceFlow.value = latest.themePreference
_useDynamicColorsFlow.value = latest.useDynamicColors
_themePresetFlow.value = latest.themePreset
_chatFontScaleFlow.value = latest.chatFontScale
syncCookieStoreForProfile(latest.selectedProfileId)
}
}
_initializationState.value = InitializationState.Ready
} catch (e: CancellationException) {
prefs.cancel(e)
throw e
} catch (e: Exception) {
prefs.completeExceptionally(e)
_initializationState.value = InitializationState.Failed
throw e
}
}
// Initialize the encrypted cookie store (issue #470). The legacy
// session-cookie prefs are passed as a Deferred so existing gated
// sessions can be migrated on first load WITHOUT blocking startup.
// The Deferred is created above, before this call.
val initialProfileId =
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
CookieManager.initialize(context, prefsDeferred, initialProfileId)
scope.launch {
store.stateFlow.collect { state ->
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
}
}
}
}
/**
* Retrieves the initialized [SharedPreferences] instance.
*
* WARNING: This method is synchronous and will block the caller thread (using [runBlocking])
* if the asynchronous initialization is still in progress. Callers should avoid invoking this
* on the main thread during early startup to prevent frame drops or potential ANRs.
*
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds.
*/
/** Suspend without blocking the caller or imposing a keystore startup deadline. */
suspend fun awaitInitialization() {
checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
}
internal suspend fun awaitPrefs(): SharedPreferences {
awaitInitialization()
return requirePrefs()
}
/** Synchronous APIs are ready-only; entry points must await initialization first. */
private fun requirePrefs(): SharedPreferences =
runBlocking {
val deferred =
prefsDeferred ?: throw IllegalStateException(
"AuthManager not initialized. Call init(context) first.",
)
kotlinx.coroutines.withTimeoutOrNull(2000) {
deferred.await()
} ?: throw IllegalStateException("AuthManager initialization timed out after 2 seconds.")
}
checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
fun setWsAuthParam(param: String) {
serverStore.update { it.copy(wsAuthParam = param) }
@@ -259,18 +316,34 @@ object AuthManager {
// ── Database Master Password ─────────────────────────────────────────
fun getDatabasePassword(): ByteArray {
val prefs = requirePrefs()
var dbPasswordBase64 = prefs.getString("db_password", null)
if (dbPasswordBase64 == null) {
val random = java.security.SecureRandom()
val newPassword = ByteArray(32)
random.nextBytes(newPassword)
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP)
prefs.edit().putString("db_password", dbPasswordBase64).apply()
suspend fun getDatabasePassword(): ByteArray =
withContext(Dispatchers.IO) {
val prefs = awaitPrefs()
synchronized(this@AuthManager) {
cachedDatabasePassword?.let { return@synchronized it.copyOf() }
val stored = prefs.getString("db_password", null)
val password =
if (stored == null) {
ByteArray(32).also { bytes ->
java.security.SecureRandom().nextBytes(bytes)
val encoded =
java.util.Base64
.getEncoder()
.encodeToString(bytes)
// Never create a database using a key that was not durably saved.
check(prefs.edit().putString("db_password", encoded).commit()) {
"Unable to persist database key"
}
}
} else {
java.util.Base64
.getDecoder()
.decode(stored)
}
cachedDatabasePassword = password
password.copyOf()
}
}
return android.util.Base64.decode(dbPasswordBase64, android.util.Base64.NO_WRAP)
}
// ── Connection Profiles ──────────────────────────────────────────────
@@ -387,15 +460,15 @@ object AuthManager {
token: String?,
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh
synchronized(this) {
cachedToken = token
cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
tokenInitialized = true
_tokenFlow.value = cachedToken
}
_tokenFlow.value = token
}
}
@@ -439,6 +512,9 @@ object AuthManager {
*/
fun setActiveProfileId(id: String?) {
val normalized = id?.takeIf { it.isNotBlank() }
// Flow first, persistence best-effort: a caller racing cold startup
// still gets the correct in-memory scope instead of an exception, and
// the write lands once initialization is Ready (#1171).
_activeProfileId.value = normalized
runCatching {
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply()
@@ -482,6 +558,13 @@ object AuthManager {
}
}
@VisibleForTesting
suspend fun resetAndAwaitForTest() {
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
resetAuthStateForTest()
previousJob?.join()
}
// For testing purposes
fun resetAuthStateForTest() {
synchronized(this) {
@@ -489,6 +572,14 @@ object AuthManager {
tokenInitialized = false
_serverStore = null
prefsDeferred = null
initialization?.cancel()
initialization = null
readyPrefs = null
cachedDatabasePassword?.fill(0)
cachedDatabasePassword = null
_initializationState.value = InitializationState.Loading
_tokenFlow.value = null
_selectedProfileFlow.value = null
appScope?.let {
try {
it.cancel()
@@ -539,6 +630,9 @@ object AuthManager {
}
fun setToken(token: String?) {
if (token == null) {
invalidateAuthGeneration()
}
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -555,6 +649,23 @@ object AuthManager {
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun currentDataScope(): DataScope? {
val rawUrl = runCatching { getBaseUrl() }.getOrNull() ?: _baseUrlFlow.value
val cleanUrl = rawUrl.trimEnd('/')
if (cleanUrl.isBlank()) return null
val connectionProfileId =
runCatching { getSelectedProfileId() }.getOrNull()
?: _selectedProfileFlow.value
?: DEFAULT_PROFILE_ID
val activeProfile = _activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
return DataScope(
connectionProfileId = connectionProfileId,
baseUrl = cleanUrl,
activeProfileId = activeProfile,
inMemoryAuthGeneration = _authGenerationFlow.value,
)
}
fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
@@ -739,6 +850,18 @@ object AuthManager {
serverStore.update { it.copy(showTokensPerSecond = enabled) }
}
fun isModelProviderShown(): Boolean = serverStore.getLatestState().showModelProvider
fun setModelProviderShown(shown: Boolean) {
serverStore.update { it.copy(showModelProvider = shown) }
}
fun isKeepConnectedInBackground(): Boolean = serverStore.getLatestState().keepConnectedInBackground
fun setKeepConnectedInBackground(enabled: Boolean) {
serverStore.update { it.copy(keepConnectedInBackground = enabled) }
}
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
@@ -750,6 +873,16 @@ object AuthManager {
// ── In-app update check (issue #867) ─────────────────────────────────
/**
* Whether the user opted into release-candidate updates. Stable-only by
* default; when true the update check also considers pre-release RC tags.
*/
fun isCheckingReleaseCandidateUpdates(): Boolean = serverStore.getLatestState().checkReleaseCandidateUpdates
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
serverStore.update { it.copy(checkReleaseCandidateUpdates = enabled) }
}
/** App version the silent update check last completed for (null = never). */
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
@@ -46,4 +46,6 @@ data class ChatMessageEntity(
val tokenCount: Int? = null,
@ColumnInfo(name = "tps")
val tps: Double? = null,
@ColumnInfo(name = "completion_id")
val completionId: String? = null,
)
@@ -37,6 +37,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -54,4 +55,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
)
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.data.local
/**
* Canonical data-scope identity representing the authenticated server and profile context.
* Used to partition caches (both in-memory and persistent) so that switching servers,
* changing server URLs, switching server-side profiles, or logging out/re-logging in
* never leaks data across boundaries.
*
* Contains no credentials, tokens, cookies, passwords, or secrets.
*/
data class DataScope(
val connectionProfileId: String,
val baseUrl: String,
val activeProfileId: String,
val inMemoryAuthGeneration: Long = 0L,
) {
/**
* Deterministic in-memory cache key.
* Uses length-prefixing for unambiguous separation of components, and includes
* [inMemoryAuthGeneration] so logging out and re-logging in automatically invalidates
* all in-memory caches.
*/
fun inMemoryKey(localKey: String = "default"): String = "g$inMemoryAuthGeneration:${persistentKey(localKey)}"
/**
* Deterministic persistent cache key for disk storage (e.g. SessionListCacheStore).
* Uses length-prefixing so no delimiter collision is possible.
* Does NOT include volatile [inMemoryAuthGeneration] so disk caches survive normal process restarts.
* Disk caches are explicitly wiped on logout.
*/
fun persistentKey(localKey: String = "default"): String =
"c${connectionProfileId.length}:$connectionProfileId:u${baseUrl.length}:$baseUrl:p${activeProfileId.length}:$activeProfileId:k${localKey.length}:$localKey"
fun scopedKey(localKey: String = "default"): String = inMemoryKey(localKey)
companion object {
val EMPTY =
DataScope(
connectionProfileId = AuthManager.DEFAULT_PROFILE_ID,
baseUrl = "",
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
inMemoryAuthGeneration = 0L,
)
}
}
@@ -7,12 +7,14 @@ import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 7,
version = 8,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -79,40 +81,55 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
// SQLCipher can't open plaintext SQLite databases — if an old
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
// create an encrypted replacement from scratch.
val dbFile = context.getDatabasePath("hermes_control.db")
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
dbFile.delete()
}
// Load SQLCipher native library before creating the driver
System.loadLibrary("sqlcipher")
val driver =
SQLCipherDriver(
AuthManager.getDatabasePassword(),
null,
null,
val MIGRATION_7_8: Migration =
object : Migration(7, 8) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
)
}
}
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).setDriver(driver)
.addMigrations(
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
val password = AuthManager.getDatabasePassword()
synchronized(this@Companion) {
instance?.let { return@synchronized it }
// SQLCipher can't open plaintext SQLite databases — if an old
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
// create an encrypted replacement from scratch.
val dbFile = context.getDatabasePath("hermes_control.db")
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
dbFile.delete()
}
// Load SQLCipher native library before creating the driver
System.loadLibrary("sqlcipher")
val driver =
SQLCipherDriver(
password,
null,
null,
)
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).setDriver(driver)
.addMigrations(
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
}
/** Returns true if the database file starts with the SQLCipher magic header. */
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
/**
* Lightweight disk cache for the initial page of sessions.
* Persists the first page JSON to SharedPreferences so the History screen
* can render instantly even after app kill / process death.
*/
object SessionListCacheStore {
private const val PREFS_NAME = "hermes_sessions_cache"
private const val KEY_PREFIX = "sessions_page_"
@Volatile
private var prefs: SharedPreferences? = null
fun init(context: Context) {
if (prefs == null) {
synchronized(this) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
}
}
fun get(key: String): SessionListResponse? {
val p = prefs ?: return null
val raw = p.getString(KEY_PREFIX + key, null) ?: return null
return try {
OkHttpProvider.json.decodeFromString<SessionListResponse>(raw)
} catch (_: Exception) {
null
}
}
fun put(
key: String,
data: SessionListResponse,
) {
val p = prefs ?: return
try {
val json = OkHttpProvider.json.encodeToString(SessionListResponse.serializer(), data)
p.edit().putString(KEY_PREFIX + key, json).apply()
} catch (_: Exception) {
// Ignore serialization issues on cache save
}
}
fun remove(key: String) {
prefs?.edit()?.remove(KEY_PREFIX + key)?.apply()
}
fun clear() {
prefs?.edit()?.clear()?.apply()
}
@VisibleForTesting
internal fun resetForTesting(testPrefs: SharedPreferences? = null) {
synchronized(this) {
prefs = testPrefs
}
}
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.data.local
/**
* Lightweight thread-safe in-memory cache for Stale-While-Revalidate screen state.
* Stores entries with optional TTL and a bounded capacity.
*
* All map operations are synchronized on a private lock so that compound
* operations (e.g. read + TTL expiry check + remove) are atomic and safe against races.
*/
class SwrCache<K : Any, V : Any>(
private val maxCapacity: Int = DEFAULT_MAX_CAPACITY,
private val ttlMillis: Long = DEFAULT_TTL_MILLIS,
private val timeProvider: () -> Long = { System.currentTimeMillis() },
) {
private data class Entry<V>(
val value: V,
val timestamp: Long,
)
private val lock = Any()
private val map =
object : LinkedHashMap<K, Entry<V>>(maxCapacity, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, Entry<V>>?): Boolean = size > maxCapacity
}
fun get(key: K): V? =
synchronized(lock) {
val entry = map[key] ?: return null
if (ttlMillis > 0 && timeProvider() - entry.timestamp > ttlMillis) {
map.remove(key)
return null
}
entry.value
}
fun put(
key: K,
value: V,
) {
synchronized(lock) {
map[key] = Entry(value, timeProvider())
}
}
fun remove(key: K): V? =
synchronized(lock) {
map.remove(key)?.value
}
fun clear() {
synchronized(lock) {
map.clear()
}
}
val size: Int
get() = synchronized(lock) { map.size }
companion object {
const val DEFAULT_MAX_CAPACITY = 50
const val DEFAULT_TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
}
}
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import kotlinx.serialization.Serializable
/**
@@ -25,22 +27,23 @@ data class Attachment(
val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL,
) {
val mediaKind: MediaKind
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
val isImage: Boolean
get() = mimeType.startsWith("image/")
get() = mediaKind == MediaKind.IMAGE
val isGif: Boolean
get() =
mimeType.equals("image/gif", ignoreCase = true) ||
fileExtension == "gif" ||
uri.contains(".gif", ignoreCase = true) ||
uri.startsWith("data:image/gif", ignoreCase = true)
val isVideo: Boolean
get() =
mimeType.startsWith("video/") ||
fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") ||
uri.contains(".mp4", ignoreCase = true) ||
uri.contains(".webm", ignoreCase = true)
get() = mediaKind == MediaKind.VIDEO
val isAudio: Boolean
get() = mediaKind == MediaKind.AUDIO
val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY
@@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable
data class SessionSearchResponse(
val results: List<SessionSearchResult> = emptyList(),
)
val has_more: Boolean? = null,
val next_offset: Int? = null,
) {
val hasMore: Boolean get() = has_more == true
}
@Serializable
data class SessionSearchResult(
@@ -15,4 +19,9 @@ data class SessionSearchResult(
val source: String? = null,
val model: String? = null,
val session_started: Double? = null,
val title: String? = null,
val last_active: Double? = null,
val message_count: Int? = null,
val archived: Boolean? = null,
val lineage_root: String? = null,
)
@@ -2,26 +2,64 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
internal object DashboardSessionTokenRefresher {
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
private val refreshMutex = Mutex()
// Synchronized so concurrent 401s / reconnect storms don't each fire a
// redundant GET at the dashboard SPA (thundering-herd guard).
@Synchronized
fun refresh(): String? =
try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token)
token
} catch (_: Exception) {
null
}
internal fun fetch(
suspend fun refreshAsync(): String? =
refreshMutex.withLock {
try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
AuthManager.setToken(token)
token
} catch (_: Exception) {
null
}
}
internal suspend fun fetch(
baseUrl: String,
client: OkHttpClient,
): String? =
withContext(Dispatchers.IO) {
try {
val request =
Request
.Builder()
.url(baseUrl)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return@withContext null
extractToken(response)
}
} catch (_: Exception) {
null
}
}
internal fun fetchSync(
baseUrl: String,
client: OkHttpClient,
): String? =
@@ -32,32 +70,31 @@ internal object DashboardSessionTokenRefresher {
.url(baseUrl)
.get()
.build()
// 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() }
}
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
extractToken(response)
}
} catch (_: Exception) {
null
}
private fun extractToken(response: Response): String? {
// 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()
}
return tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
private const val MAX_BODY_BYTES: Long = 64 * 1024
}
@@ -277,6 +277,7 @@ object GatewayFileClient {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read < 0) break
coroutineContext.ensureActive()
output.write(buffer, 0, read)
}
}
@@ -189,6 +189,8 @@ interface HermesApiService : KanbanApiService {
@Query("profile") profile: String? = null,
@Query("source") source: String? = null,
@Query("exclude_sources") excludeSources: String? = null,
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0,
): Response<SessionSearchResponse>
@GET("api/sessions/{id}/messages")
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
@OptIn(UnstableApi::class)
object MediaDataSourceProvider {
class SameOriginAuthInterceptor(
private val baseUrlProvider: () -> String = { runCatching { AuthManager.baseUrl() }.getOrDefault("") },
private val tokenProvider: () -> String? = { runCatching { AuthManager.getToken() }.getOrNull() },
private val isGatedModeProvider: () -> Boolean = {
runCatching { AuthManager.isGatedMode() }.getOrDefault(
false,
)
},
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// In gated mode (basic auth), the dashboard authenticates via session cookies;
// stamping an Authorization: Bearer header causes 401s (issue #470).
if (isGatedModeProvider()) {
return chain.proceed(request)
}
val url = request.url
val base = baseUrlProvider().toHttpUrlOrNull()
val isSameOrigin =
base != null &&
url.scheme.equals(base.scheme, ignoreCase = true) &&
url.host.equals(base.host, ignoreCase = true) &&
url.port == base.port
if (isSameOrigin) {
val token = tokenProvider()
if (!token.isNullOrBlank() && request.header("Authorization") == null) {
return chain.proceed(
request
.newBuilder()
.header("Authorization", "Bearer $token")
.build(),
)
}
}
return chain.proceed(request)
}
}
@Volatile
internal var testClient: OkHttpClient? = null
val mediaOkHttpClient: OkHttpClient
get() = testClient ?: defaultClient
private val defaultClient: OkHttpClient by lazy {
OkHttpProvider.base
.newBuilder()
.addInterceptor(SameOriginAuthInterceptor())
.build()
}
fun createDataSourceFactory(context: Context): DataSource.Factory {
val httpDataSourceFactory = OkHttpDataSource.Factory(mediaOkHttpClient)
return DefaultDataSource.Factory(context, httpDataSourceFactory)
}
}
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -19,6 +22,10 @@ object NetworkMonitor {
private val networkChangeEvents = MutableSharedFlow<Boolean>(extraBufferCapacity = Int.MAX_VALUE)
internal val networkChanges: SharedFlow<Boolean> = networkChangeEvents.asSharedFlow()
// Emitted when default network transports change without onLost/onAvailable (issue #1165).
private val transportChangeEvents = MutableSharedFlow<Unit>(extraBufferCapacity = Int.MAX_VALUE)
internal val transportChanges: SharedFlow<Unit> = transportChangeEvents.asSharedFlow()
private val callbackLock = Any()
private val callbackGeneration = AtomicInteger(0)
private var callbackRegistration: Pair<ConnectivityManager, ConnectivityManager.NetworkCallback>? = null
@@ -26,6 +33,9 @@ object NetworkMonitor {
@Volatile
private var defaultNetwork: Network? = null
@Volatile
private var currentTransports: Set<Int>? = null
fun init(context: Context) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = createCallback(cm)
@@ -38,6 +48,10 @@ object NetworkMonitor {
defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null
_isConnected.value = networkAvailable
currentTransports =
runCatching {
activeNetwork?.let { cm.getNetworkCapabilities(it) }?.let { extractTransports(it) }
}.getOrNull()
callbackRegistration.also { callbackRegistration = cm to callback }
}
if (networkChanged) networkChangeEvents.tryEmit(networkAvailable)
@@ -56,6 +70,7 @@ object NetworkMonitor {
if (callbackGeneration.get() != generation) return
(defaultNetwork != network).also {
defaultNetwork = network
currentTransports = null
_isConnected.value = true
}
}
@@ -71,11 +86,86 @@ object NetworkMonitor {
(defaultNetwork != activeNetwork).also {
defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null
currentTransports = null
_isConnected.value = networkAvailable
}
}
if (changed) networkChangeEvents.tryEmit(networkAvailable)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
val transportChanged: Boolean
synchronized(callbackLock) {
if (callbackGeneration.get() != generation || defaultNetwork != network) return
val newTransports = extractTransports(networkCapabilities)
val oldTransports = currentTransports
currentTransports = newTransports
transportChanged = oldTransports != null && oldTransports != newTransports
}
if (transportChanged) {
transportChangeEvents.tryEmit(Unit)
}
}
override fun onLosing(
network: Network,
maxMsToLive: Int,
) {
val isDefault: Boolean
synchronized(callbackLock) {
if (callbackGeneration.get() != generation || defaultNetwork != network) return
isDefault = true
}
if (isDefault) {
transportChangeEvents.tryEmit(Unit)
}
}
}
}
internal fun extractTransports(capabilities: NetworkCapabilities?): Set<Int> {
if (capabilities == null) return emptySet()
val transports = mutableSetOf<Int>()
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
transports.add(NetworkCapabilities.TRANSPORT_CELLULAR)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
transports.add(NetworkCapabilities.TRANSPORT_WIFI)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) {
transports.add(NetworkCapabilities.TRANSPORT_BLUETOOTH)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
transports.add(NetworkCapabilities.TRANSPORT_ETHERNET)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
transports.add(NetworkCapabilities.TRANSPORT_VPN)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) {
transports.add(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_LOWPAN)
) {
transports.add(NetworkCapabilities.TRANSPORT_LOWPAN)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB)
) {
transports.add(NetworkCapabilities.TRANSPORT_USB)
}
return transports
}
@VisibleForTesting
internal fun resetForTest() {
synchronized(callbackLock) {
defaultNetwork = null
currentTransports = null
_isConnected.value = true
}
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
}
val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
return NetworkResult.Failure(
NetworkError.Unknown(
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -31,6 +32,18 @@ import kotlinx.coroutines.withContext
* 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
*/
object ProfileSwitchCoordinator {
/**
* Dispatcher for the blocking network hops below.
*
* Injectable so tests can drive the whole switch on a TestDispatcher. With the
* real Dispatchers.IO these paths hop to a live thread pool and the ORDER of
* the mocked calls becomes load-dependent -- ProfileSwitchCoordinatorTest's
* Ordering.SEQUENCE checks passed on an idle machine but lost 2 tests while the
* emulator saturated the CPU, and its setMain-less sibling tests failed
* outright whenever another class had left Dispatchers.Main broken.
*/
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
val switched: SharedFlow<String> = _switched.asSharedFlow()
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
suspend fun switchProfile(name: String): NetworkResult<Unit> {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
}
if (result !is NetworkResult.Success) return result
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
// run off the main thread or the dial crashes with
// NetworkOnMainThreadException and falls back to the 1s reconnect
// retry (visible in the 2026-08-06 live logcat).
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
HermesWsClient.disconnect()
HermesWsClient.connect()
}
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
AuthManager.setSelectedProfileId(profileId)
ApiClient.rebuild()
_connectionSwitched.emit(profileId.orEmpty())
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
// The WS ticket mint reads the cookie jar's ACTIVE store; the
// selection change swaps that store asynchronously, so a dial
// that races it mints with the PREVIOUS server's cookie → 401 →
@@ -18,6 +18,9 @@ import java.io.IOException
data class UpdateInfo(
val tagName: String = "",
val body: String = "",
/** GitHub release flags: a draft is unpublished, a prerelease is pre-stable. */
val draft: Boolean = false,
val prerelease: Boolean = false,
val assets: List<Asset> = emptyList(),
) {
@Serializable
@@ -36,33 +39,105 @@ data class UpdateInfo(
fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v")
/**
* True when [latest] is strictly newer than [current]. Numeric dot-segment
* comparison ("1.21.0" > "1.2"). Non-numeric suffixes (e.g. "1.0-dev")
* compare as 0; an unparseable version never claims an update exists.
* True when [latest] is strictly newer than [current] (issue #867).
* Compare the numeric core first, padding missing parts with zero, then
* prerelease identifiers. Stable outranks prerelease for the same core.
* Accept both "1.25.0-rc.1" and legacy "1.25.rc.1"; ignore build metadata.
* Invalid versions never claim an update exists.
*/
fun isNewerVersion(
latest: String,
current: String,
): Boolean {
val a = versionSegments(latest) ?: return false
val b = versionSegments(current) ?: return false
for (i in 0 until maxOf(a.size, b.size)) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
val a = parseVersion(latest) ?: return false
val b = parseVersion(current) ?: return false
for (i in 0 until maxOf(a.core.size, b.core.size)) {
val comparison = a.core.getOrElse(i) { 0 }.compareTo(b.core.getOrElse(i) { 0 })
if (comparison != 0) return comparison > 0
}
return false
if (a.prerelease.isEmpty()) return b.prerelease.isNotEmpty()
if (b.prerelease.isEmpty()) return false
for (i in 0 until minOf(a.prerelease.size, b.prerelease.size)) {
val comparison = comparePrereleaseIdentifier(a.prerelease[i], b.prerelease[i])
if (comparison != 0) return comparison > 0
}
return a.prerelease.size > b.prerelease.size
}
private fun versionSegments(version: String): List<Int>? {
val cleaned = normalizedVersion(version)
if (cleaned.isBlank()) return null
return cleaned.split('.').map { segment ->
// Leading numeric portion of each segment: "0-dev" → 0, "21" → 21.
segment.takeWhile { it.isDigit() }.toIntOrNull() ?: 0
}
/**
* Pick the newest installable release out of a GitHub `releases` list payload.
*
* Drafts and releases without an APK asset are always ignored. Pre-releases are
* only eligible when [includeReleaseCandidates] is set *and* the tag is a
* release candidate — alpha/beta/dev pre-releases stay out of the RC channel.
*
* Selection uses [isNewerVersion] rather than list order because the API orders
* releases by publication date, which is not a version oracle.
*/
fun selectLatestUpdate(
releases: List<UpdateInfo>,
includeReleaseCandidates: Boolean = false,
): UpdateInfo? =
releases
.filter { !it.draft && it.apkAsset != null }
.filter {
if (!it.prerelease) {
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
}
private data class ParsedVersion(
val core: List<Int>,
val prerelease: List<String>,
)
private val VERSION_PATTERN =
Regex(
"""^([0-9]+(?:\.[0-9]+)*)(?:(?:-|\.(?=[a-zA-Z]))([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)?$""",
)
private fun parseVersion(version: String): ParsedVersion? {
val match = VERSION_PATTERN.matchEntire(normalizedVersion(version)) ?: return null
val core = match.groupValues[1].split('.').map { it.toIntOrNull() ?: return null }
val prerelease =
match.groupValues[2]
.takeIf { it.isNotEmpty() }
?.split('.')
.orEmpty()
return ParsedVersion(core, prerelease)
}
private fun comparePrereleaseIdentifier(
a: String,
b: String,
): Int {
val aNumeric = a.all { it in '0'..'9' }
val bNumeric = b.all { it in '0'..'9' }
if (aNumeric != bNumeric) return if (aNumeric) -1 else 1
if (!aNumeric) return a.compareTo(b)
// Compare arbitrarily large RC numbers without integer overflow.
val x = a.trimStart('0')
val y = b.trimStart('0')
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
}
/** `rc`, `rc1`, `RC2` — but never alpha/beta/dev. */
private val RC_IDENTIFIER = Regex("^rc[0-9]*$", RegexOption.IGNORE_CASE)
/**
* True when [tag] is a release-candidate version such as "v1.25.0-rc.1",
* "v1.25-rc1", or the legacy dot form "v1.25.rc.1".
*
* Only the first pre-release identifier is inspected, so alpha/beta builds are
* never treated as release candidates.
*/
fun isReleaseCandidateVersion(tag: String): Boolean =
parseVersion(tag)?.prerelease?.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
/**
* Talks to the GitHub releases API and downloads the release APK (issue
* #867). Network methods are `open` so tests can fake them; the pure logic
@@ -71,27 +146,44 @@ private fun versionSegments(version: String): List<Int>? {
*/
open class AppUpdateChecker(
private val client: OkHttpClient = OkHttpProvider.base,
private val apiBaseUrl: String = "https://api.github.com/repos/Hy4ri/hermes-mobile",
) {
/**
* Fetch the latest release metadata. Returns null when there is no
* release yet (404) or the body can't be parsed. Throws [IOException]
* on network failure so the caller can surface a friendly error.
* Fetch the newest installable release metadata.
*
* Stable-only by default: the `releases/latest` endpoint deliberately
* excludes pre-releases. With [includeReleaseCandidates] the releases list
* is scanned instead so a newer release-candidate tag becomes eligible.
*
* Returns null when there is no usable release (404) or the body can't be
* parsed. Throws [IOException] on network failure so the caller can
* surface a friendly error.
*/
open suspend fun fetchLatestRelease(): UpdateInfo? =
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body.string().orEmpty()
if (includeReleaseCandidates) {
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
} else {
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
parseUpdateInfo(body)
}
}
/** GET [url] as JSON text, or null on any non-2xx response. */
private fun get(url: String): String? {
val request =
Request
.Builder()
.url(url)
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body.string().orEmpty()
}
}
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
@@ -134,6 +226,9 @@ open class AppUpdateChecker(
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
/** How many recent releases the opt-in RC scan inspects. */
const val RELEASE_LIST_PAGE_SIZE = 20
}
}
@@ -142,3 +237,9 @@ fun parseUpdateInfo(json: String): UpdateInfo? =
runCatching {
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
}.getOrNull()
/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */
fun parseReleaseList(json: String): List<UpdateInfo>? =
runCatching {
OkHttpProvider.json.decodeFromString<List<UpdateInfo>>(json)
}.getOrNull()
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
// Captured once so the check and the notice path agree on the channel.
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease()
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
// The user may have switched back to stable while this request was in
// flight — an RC result must never be published on the stable channel.
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return@launch
}
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
/**
* The tag the chat banner should advertise, or null when nothing newer is
* known or if the user explicitly dismissed this tag.
* known, if the user explicitly dismissed this tag, or if it is a
* release-candidate tag while the user is on the stable channel.
*/
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
?: return null
if (candidateTag == dismissed) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
import kotlinx.serialization.json.JsonObject
/**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
@@ -38,8 +39,22 @@ object EventParser {
}
// ── Notification / event (no id, has method) ─────────────────────
val jsonParams = response.params ?: return WsEvent.Unknown(rawJson)
// Issue #1163: token events need only scalar lookups, never recursive map/list copies.
val eventType = jsonParams["type"].eventStringOrNull()
if (eventType == "message.token" || eventType == "message.delta" ||
eventType == "thinking.delta" || eventType == "reasoning.delta"
) {
val sessionId = jsonParams.eventSessionId()
val token = (jsonParams["payload"] as? JsonObject)?.get("text").eventStringOrNull() ?: ""
return when (eventType) {
"thinking.delta" -> WsEvent.ThinkingDelta(token, sessionId)
"reasoning.delta" -> WsEvent.ReasoningDelta(token, sessionId)
else -> WsEvent.MessageToken(token, sessionId)
}
}
@Suppress("UNCHECKED_CAST")
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
val params = jsonParams.toAny() as Map<String, Any?>
return parseParams(params, rawJson)
}
@@ -95,7 +110,18 @@ object EventParser {
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
val reasoning = payload?.get("reasoning") as? String
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
val completionId =
payload?.get("completion_id") as? String
?: java.util.UUID
.randomUUID()
.toString()
WsEvent.MessageComplete(
text,
sessionId,
reasoning,
rawPayload = payload,
completionId = completionId,
)
}
"message.done" -> {
@@ -113,14 +139,16 @@ object EventParser {
}
"tool.progress" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") as? String
val preview = payload?.get("preview") as? String
WsEvent.ToolProgress(name, preview, sessionId)
WsEvent.ToolProgress(name, preview, sessionId, toolId)
}
"tool.generating" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") as? String
WsEvent.ToolGenerating(name, sessionId)
WsEvent.ToolGenerating(name, sessionId, toolId)
}
"subagent.spawn_requested", "subagent.start", "subagent.progress", "subagent.complete" -> {
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -106,21 +107,24 @@ object HermesWsClient {
/**
* Liveness thresholds aligned with reference clients (desktop/web):
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
* Ping every 15s using gateway.ping; 30s inbound silence deadline (issue #1165)
* as mobile secondary safety net for faster half-open socket recovery.
*/
private const val HEARTBEAT_INTERVAL_MS = 15_000L
private const val STALE_THRESHOLD_MS = 45_000L
private const val STALE_THRESHOLD_MS = 30_000L
private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
// ── Internal state (all access through synchronized / atomic) ────────
private val requestId = AtomicInteger(0)
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
private val connectionGeneration = AtomicInteger(0)
private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false)
private val acceptQueuedMessages = AtomicBoolean(true)
private val appInForeground = AtomicBoolean(true)
private val externalActivityConnectionLease = AtomicBoolean(false)
private val backgroundConnectionLease = AtomicBoolean(false)
private val messageQueue = ConcurrentLinkedQueue<String>()
private val queuedMessagesById = ConcurrentHashMap<String, String>()
private val outboundLock = Any()
@@ -351,6 +355,16 @@ object HermesWsClient {
wsScope.launch {
events.collect { event ->
if (event is WsEvent.GatewayReady) {
// Advertise support for gateway server→client requests (issue #1197).
// Older gateways may reject this method; that is harmless.
runCatching {
send(
WsMethods.CLIENT_CAPABILITIES,
mapOf("server_requests" to true),
onSent = { id -> capabilityRequestIds.add(id) },
queueIfDisconnected = false,
)
}
val epoch = event.data?.get("replay_epoch") as? String
if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) {
@@ -369,6 +383,7 @@ object HermesWsClient {
val isConnected: Boolean get() = connected.get()
private var foregroundProbeJob: Job? = null
private var transportProbeJob: Job? = null
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
@@ -398,6 +413,29 @@ object HermesWsClient {
}
}
@VisibleForTesting
internal fun probeLivenessOnTransportChange(
onFailureAction: () -> Unit = {
synchronized(outboundLock) {
if (connected.get()) webSocket?.cancel()
}
},
) {
if (!connected.get()) return
transportProbeJob?.cancel()
transportProbeJob =
wsScope.launch {
Log.d(TAG, "Network transport changed — probing WebSocket liveness")
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
if (alive) {
Log.d(TAG, "WebSocket liveness probe succeeded on new transport")
return@launch
}
Log.w(TAG, "Transport change liveness probe failed — cancelling socket")
onFailureAction()
}
}
fun acquireExternalActivityConnectionLease() {
externalActivityConnectionLease.set(true)
}
@@ -407,10 +445,23 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
fun acquireBackgroundConnectionLease() {
backgroundConnectionLease.set(true)
}
fun releaseBackgroundConnectionLease() {
backgroundConnectionLease.set(false)
disconnectIfIdleInBackground()
}
@VisibleForTesting
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
private fun disconnectIfIdleInBackground() {
synchronized(outboundLock) {
if (!appInForeground.get() &&
!externalActivityConnectionLease.get() &&
!backgroundConnectionLease.get() &&
!pendingReply &&
pendingCalls.isEmpty() &&
messageQueue.isEmpty()
@@ -428,6 +479,11 @@ object HermesWsClient {
reconnectForNetworkChange(networkAvailable)
}
}
wsScope.launch {
NetworkMonitor.transportChanges.collect {
probeLivenessOnTransportChange()
}
}
}
var socketToCancel: WebSocket? = null
val shouldOpen =
@@ -517,7 +573,7 @@ object HermesWsClient {
* or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed.
*/
internal fun refreshWsTicketIfNeeded(generation: Int): Boolean {
internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -533,11 +589,9 @@ object HermesWsClient {
// restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
val token =
synchronized(DashboardSessionTokenRefresher) {
runCatching {
DashboardSessionTokenRefresher.fetch(AuthManager.baseUrl(), OkHttpProvider.probe)
}.getOrNull()
}
runCatching {
DashboardSessionTokenRefresher.refreshAsync()
}.getOrNull()
synchronized(outboundLock) {
if (isCurrentGeneration(generation) && token != null) {
runCatching { AuthManager.setToken(token) }
@@ -580,7 +634,7 @@ object HermesWsClient {
* ticket. Loopback mode: refresh the dashboard token and return it.
* Returns null when a ticket could not be obtained.
*/
internal fun mintWsTicket(): String? {
internal suspend fun mintWsTicket(): String? {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -588,7 +642,7 @@ object HermesWsClient {
false
}
if (!isGated) {
DashboardSessionTokenRefresher.refresh()
DashboardSessionTokenRefresher.refreshAsync()
return AuthManager.getToken()
}
return requestWsTicket().ticket
@@ -601,7 +655,7 @@ object HermesWsClient {
)
/** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */
private fun requestWsTicket(): TicketRequestResult {
private suspend fun requestWsTicket(): TicketRequestResult {
try {
val client = OkHttpProvider.probe
val request =
@@ -611,16 +665,12 @@ object HermesWsClient {
.post("{}".toRequestBody())
.build()
// 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) {
withContext(Dispatchers.IO) {
if (CookieManager.isInitialized()) {
CookieManager.useStore(CookieManager.cookieJar.currentServer())
}
client.newCall(request).execute().use { resp ->
client.newCall(request).await().use { resp ->
resp.code to resp.body.string()
}
}
@@ -682,7 +732,9 @@ object HermesWsClient {
webSocket?.close(1000, "Client closed")
webSocket = null
closingSocket = null
capabilityRequestIds.clear()
if (clearPendingMessages) {
backgroundConnectionLease.set(false)
messageQueue.clear()
queuedMessagesById.clear()
pendingPromptSubmits.clear()
@@ -869,6 +921,13 @@ object HermesWsClient {
method: String,
params: Map<String, Any> = emptyMap(),
onSent: ((String) -> Unit)? = null,
): String = send(method, params, onSent, true)
private fun send(
method: String,
params: Map<String, Any>,
onSent: ((String) -> Unit)?,
queueIfDisconnected: Boolean,
): String {
val id = requestId.incrementAndGet().toString()
val decoratedParams = WsProfileParams.decorate(method, params)
@@ -888,13 +947,15 @@ object HermesWsClient {
pendingPromptSubmits.add(id)
pendingReply = true
}
onSent?.invoke(id)
val ws = webSocket
if (ws != null && connected.get()) {
// Never replay send(true): the server may have executed it even if its response is lost.
if (!ws.send(json)) {
if (ws.send(json)) {
onSent?.invoke(id)
} else {
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
if (isRetryableMessage(json)) {
if (queueIfDisconnected && isRetryableMessage(json)) {
onSent?.invoke(id)
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
queueMessage(id, json)
recoverRejectedSocket(ws)
@@ -902,8 +963,9 @@ object HermesWsClient {
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
}
}
} else if (acceptQueuedMessages.get()) {
} else if (acceptQueuedMessages.get() && queueIfDisconnected) {
if (isRetryableMessage(json)) {
onSent?.invoke(id)
Log.d(TAG, "WS disconnected — queuing message")
queueMessage(id, json)
reconnect = true
@@ -1154,22 +1216,24 @@ object HermesWsClient {
if (intentionalClose.get()) return
connectionGeneration.incrementAndGet()
}
if (!refreshWsTicketIfNeeded(generation)) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return
}
if (!isCurrentGeneration(generation)) return
val url = AuthManager.wsUrl()
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
wsScope.launch(Dispatchers.IO) {
if (!refreshWsTicketIfNeeded(generation)) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return@launch
}
if (!isCurrentGeneration(generation)) return@launch
val url = AuthManager.wsUrl()
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket
} else {
newSocket.cancel()
val request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket
} else {
newSocket.cancel()
}
}
}
}
@@ -1237,6 +1301,26 @@ object HermesWsClient {
) : WebSocketListener() {
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
private fun consumeCapabilityResponse(event: WsEvent): Boolean {
val id =
when (event) {
is WsEvent.RpcResult -> event.id
is WsEvent.RpcError -> event.id
else -> return false
}
// send() registers capability request IDs via onSent while holding
// outboundLock. Take the same lock here so a very fast gateway
// response cannot be consumed before its ID has been registered.
val isCapabilityResponse =
synchronized(outboundLock) {
capabilityRequestIds.remove(id)
}
if (!isCapabilityResponse) return false
removeQueuedMessage(id)
return true
}
override fun onOpen(
webSocket: WebSocket,
response: Response,
@@ -1316,12 +1400,9 @@ object HermesWsClient {
}
if (rpc.id == null) {
@Suppress("UNCHECKED_CAST")
val params = rpc.params?.toAny() as? Map<String, Any?>
val sid =
(params?.get("session_id") as? String)
?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String)
val seq = (params?.get("seq") as? Number)?.toInt()
// Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
val sid = rpc.params?.eventSessionId()
val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
if (!sid.isNullOrBlank() && seq != null) {
val prev = lastSeenSeq[sid] ?: 0
@@ -1360,6 +1441,7 @@ object HermesWsClient {
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
WsEvent.Unknown(text)
}
if (consumeCapabilityResponse(event)) return
when (event) {
is WsEvent.RpcResult -> {
synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) }
@@ -1426,6 +1508,7 @@ object HermesWsClient {
connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking()
capabilityRequestIds.clear()
if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
@@ -1454,6 +1537,7 @@ object HermesWsClient {
connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking()
capabilityRequestIds.clear()
val code = response?.code ?: 0
if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true ||
@@ -102,6 +102,13 @@ fun Any?.toJsonElement(): JsonElement =
}
}
// Issue #1163: scalar conversion preserves toAny() type semantics without copying JSON containers.
internal fun JsonElement?.eventStringOrNull(): String? = (this as? JsonPrimitive)?.toAny() as? String
internal fun JsonObject.eventSessionId(): String? =
get("session_id").eventStringOrNull()
?: (get("payload") as? JsonObject)?.get("session_id").eventStringOrNull()
fun JsonElement.toAny(): Any? =
when (this) {
is JsonNull -> {
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -163,86 +164,89 @@ class KanbanEventsClient {
) {
if (closed || gen != generation) return
val credential = HermesWsClient.mintWsTicket()
if (credential.isNullOrBlank()) {
onStatus(KanbanLiveStatus.AUTH_FAILED)
return
}
scope.launch(Dispatchers.IO) {
val credential = HermesWsClient.mintWsTicket()
if (closed || gen != generation) return@launch
if (credential.isNullOrBlank()) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return@launch
}
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url =
AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH,
authParameter = authParam,
credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board),
)
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url =
AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH,
authParameter = authParam,
credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board),
)
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
webSocket =
httpClient.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (closed || gen != generation) return
val envelope = parseKanbanEventsFrame(text) ?: return
if (envelope.events.isEmpty()) return
envelope.cursor?.let { cursor = it }
scope.launch { onEvents(envelope) }
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) = Unit
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
if (closed || gen != generation) return
if (code == WS_1008_POLICY_VIOLATION) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return
val request = Request.Builder().url(url).build()
webSocket =
httpClient.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
}
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (closed || gen != generation) return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
},
)
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (closed || gen != generation) return
val envelope = parseKanbanEventsFrame(text) ?: return
if (envelope.events.isEmpty()) return
envelope.cursor?.let { cursor = it }
scope.launch { onEvents(envelope) }
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) = Unit
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
if (closed || gen != generation) return
if (code == WS_1008_POLICY_VIOLATION) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return
}
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (closed || gen != generation) return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
},
)
}
}
private fun scheduleReconnect(
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ModelOptionsResponse
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
/** Issue #1164: profile-configured inventory; never pass a live chat session override. */
class ModelOptionsRepository(
private val connected: () -> Boolean = {
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
},
private val request: suspend (Map<String, Any>) -> Any? = { params ->
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
try {
deferred.await()
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
},
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
},
) {
suspend fun load(refresh: Boolean = false): NetworkResult<ModelOptionsResponse> {
currentCoroutineContext().ensureActive()
if (connected()) {
try {
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
// request() returns raw JsonElement, unlike the pushed-event Map representation.
val json = result as? JsonElement ?: error("Invalid model.options result")
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
} catch (e: CancellationException) {
// A disconnected socket may cancel the independent RPC deferred. Only fall back
// if the screen's own coroutine is still active; never swallow caller cancellation.
currentCoroutineContext().ensureActive()
} catch (_: Exception) {
// Older gateways and malformed payloads keep the existing HTTP behavior.
}
}
currentCoroutineContext().ensureActive()
return rest(refresh)
}
}
@@ -63,6 +63,11 @@ sealed class WsEvent {
val storedSessionId: String? = null,
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
val rawPayload: Map<String, Any?>? = null,
/** Stable completion identity for notification and read-tracking correlation. */
val completionId: String =
java.util.UUID
.randomUUID()
.toString(),
) : WsEvent()
data class MessageDone(
@@ -104,23 +109,25 @@ sealed class WsEvent {
* Live tool execution progress with optional preview content.
*
* Events: `tool.progress`
* Payload: `{ name?: string, preview?: string }`
* Payload: `{ tool_id?: string, name?: string, preview?: string }`
*/
data class ToolProgress(
val name: String? = null,
val preview: String? = null,
val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent()
/**
* Tool generation active state.
*
* Events: `tool.generating`
* Payload: `{ name?: string }`
* Payload: `{ tool_id?: string, name?: string }`
*/
data class ToolGenerating(
val name: String? = null,
val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent()
/**
@@ -28,6 +28,7 @@ object WsMethods {
const val SESSION_USAGE = "session.usage"
const val PROFILES_CONFIGURE = "profiles.configure"
const val PROFILES_LIST = "profiles.list"
const val MODEL_OPTIONS = "model.options"
// ── Projects ──────────────────────────────────────────────────────────
@@ -65,6 +66,7 @@ object WsMethods {
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
const val GATEWAY_PING = "gateway.ping"
const val CLIENT_CAPABILITIES = "client.capabilities"
// ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog"
@@ -154,6 +156,7 @@ object WsMethods {
// Evidence: methods_session.py lines 42, 164, 232, 317, 845
SESSION_CREATE, // session.create
SESSION_LIST, // session.list
MODEL_OPTIONS, // model.options: @_profile_scoped
SESSION_RESUME, // session.resume
SESSION_DELETE, // session.delete
SESSION_STATUS, // session.status
@@ -0,0 +1,115 @@
package com.m57.hermescontrol.notification
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.ws.HermesWsClient
/**
* Coordinates background connection leases and foreground service lifecycle
* based on [BackgroundConnectionPolicy].
*/
class BackgroundConnectionController(
private val snapshotProvider: (isDeparting: Boolean) -> BackgroundConnectionSnapshot = { isDeparting ->
defaultSnapshot(isDeparting)
},
private val acquireLease: () -> Unit = { HermesWsClient.acquireBackgroundConnectionLease() },
private val releaseLease: () -> Unit = { HermesWsClient.releaseBackgroundConnectionLease() },
private val requestServiceStart: (() -> Unit) -> Unit = { startAction ->
ChatNotificationService.lifecycle.start(startAction)
},
private val requestServiceStop: () -> Unit = { ChatNotificationService.lifecycle.stop() },
private val requestServiceComplete: (Long) -> Unit = { generation ->
ChatNotificationService.lifecycle.complete(generation)
},
private val onNotificationStateChanged: (BackgroundNotificationState) -> Unit = { state ->
ChatNotificationService.updateForegroundNotification(state)
},
) {
companion object {
private const val TAG = "BgConnectionController"
val default: BackgroundConnectionController by lazy { BackgroundConnectionController() }
fun defaultSnapshot(isDeparting: Boolean = false): BackgroundConnectionSnapshot {
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) {
return BackgroundConnectionSnapshot.ineligible(
appInForeground = ChatNotificationService.isAppInForeground(),
isDeparting = isDeparting,
)
}
val status = HermesWsClient.connectionStatus.value
val isEligible = AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()
return BackgroundConnectionSnapshot(
appInForeground = ChatNotificationService.isAppInForeground(),
isDeparting = isDeparting,
keepConnectedOptIn = AuthManager.isKeepConnectedInBackground(),
pendingReply = HermesWsClient.pendingReply,
isEligibleForConnection = isEligible,
status = status,
isAutoReconnect = AuthManager.isAutoReconnect(),
hasActiveNetwork = NetworkMonitor.isConnected.value,
)
}
}
private var currentNotificationState: BackgroundNotificationState = BackgroundNotificationState.None
fun onAppPause(startServiceAction: () -> Unit) {
val snapshot = snapshotProvider(true)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (!decision.shouldHoldService) {
return
}
if (decision.shouldHoldPersistentLease) {
acquireLease()
}
try {
requestServiceStart(startServiceAction)
} catch (e: Exception) {
Log.w(TAG, "Failed to start background service, rolling back lease", e)
if (decision.shouldHoldPersistentLease) {
releaseLease()
}
}
}
fun onAppResume() {
releaseLease()
requestServiceStop()
}
fun onReplyCompleted(generation: Long) {
val snapshot = snapshotProvider(false).copy(pendingReply = false)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (decision.shouldHoldService) {
if (decision.notificationState != currentNotificationState) {
currentNotificationState = decision.notificationState
onNotificationStateChanged(decision.notificationState)
}
} else {
requestServiceComplete(generation)
}
}
fun reconcileState() {
val snapshot = snapshotProvider(false)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (!decision.shouldHoldService && !snapshot.appInForeground) {
releaseLease()
requestServiceStop()
} else if (decision.shouldHoldService) {
if (decision.notificationState != currentNotificationState) {
currentNotificationState = decision.notificationState
onNotificationStateChanged(decision.notificationState)
}
}
}
}
@@ -0,0 +1,141 @@
package com.m57.hermescontrol.notification
import com.m57.hermescontrol.data.ws.ConnectionStatus
/**
* Snapshot of runtime state required to determine background connection policy.
*/
data class BackgroundConnectionSnapshot(
val appInForeground: Boolean,
val isDeparting: Boolean = false,
val keepConnectedOptIn: Boolean,
val pendingReply: Boolean,
val isEligibleForConnection: Boolean,
val status: ConnectionStatus,
val isAutoReconnect: Boolean = true,
val hasActiveNetwork: Boolean = true,
) {
companion object {
fun ineligible(
appInForeground: Boolean = false,
isDeparting: Boolean = false,
): BackgroundConnectionSnapshot =
BackgroundConnectionSnapshot(
appInForeground = appInForeground,
isDeparting = isDeparting,
keepConnectedOptIn = false,
pendingReply = false,
isEligibleForConnection = false,
status = ConnectionStatus.DISCONNECTED,
isAutoReconnect = false,
hasActiveNetwork = false,
)
}
}
/**
* User-visible state of the ongoing background notification.
*/
enum class BackgroundNotificationState {
None,
WaitingForNetwork,
Connecting,
Reconnecting,
WaitingForReplies,
ConnectedInBackground,
}
/**
* Evaluated decision for foreground service and connection retention.
*/
data class BackgroundConnectionDecision(
val shouldHoldService: Boolean,
val shouldHoldPersistentLease: Boolean,
val notificationState: BackgroundNotificationState,
)
/**
* Pure policy evaluation for background connection lifecycle.
*/
object BackgroundConnectionPolicy {
fun evaluate(snapshot: BackgroundConnectionSnapshot): BackgroundConnectionDecision {
// Terminal failure / unauthenticated / ineligible state
if (!snapshot.isEligibleForConnection || snapshot.status == ConnectionStatus.AUTH_EXPIRED) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// When the app is in foreground and not departing, background service/lease are not needed
if (snapshot.appInForeground && !snapshot.isDeparting) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// Demand check: need either a pending reply or persistent opt-in
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
if (!hasDemand) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// When Auto-Reconnect is disabled, HermesWsClient will not reconnect once disconnected or offline.
// Even if a reply was pending, it can never arrive without reconnecting.
if (!snapshot.isAutoReconnect) {
when (snapshot.status) {
ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
-> {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
else -> { /* CONNECTED, CONNECTING, RECONNECTING can proceed */ }
}
}
val notificationState =
when {
!snapshot.hasActiveNetwork || snapshot.status == ConnectionStatus.NO_NETWORK -> {
BackgroundNotificationState.WaitingForNetwork
}
snapshot.status == ConnectionStatus.RECONNECTING -> {
BackgroundNotificationState.Reconnecting
}
snapshot.pendingReply -> {
BackgroundNotificationState.WaitingForReplies
}
snapshot.status == ConnectionStatus.CONNECTED -> {
BackgroundNotificationState.ConnectedInBackground
}
snapshot.status == ConnectionStatus.CONNECTING -> {
BackgroundNotificationState.Connecting
}
else -> {
BackgroundNotificationState.Reconnecting
}
}
return BackgroundConnectionDecision(
shouldHoldService = true,
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
notificationState = notificationState,
)
}
}
@@ -13,7 +13,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -43,16 +43,16 @@ import java.util.concurrent.atomic.AtomicBoolean
* message notifications, which is correct.
*
* Lifecycle:
* - Started by [NotificationHelper.start] when the app goes to the
* background while a reply is still pending (the user sent a message or
* replied from a notification and the agent has not finished yet).
* [NotificationHelper.start] is a no-op when nothing is pending, so the
* service — and its mandatory persistent notification — only exists
* while the user is actually waiting for a reply (issue #794).
* - Stopped by [NotificationHelper.stop] when the app returns to the
* foreground (called from MainActivity.onStart), or by the
* service itself once the pending reply completes in the background
* (the reply notification replaces the persistent "waiting" one).
* - Started by [NotificationHelper.start] when the app goes to the background
* either while a reply is still pending (issue #794) or when persistent background
* connection ([com.m57.hermescontrol.data.local.AuthManager.isKeepConnectedInBackground])
* is enabled.
* - In persistent keep-connected mode, the service stays alive across idle states
* and updates its ongoing notification truthfully ([BackgroundNotificationState]).
* - In replies-only mode (default), the service retires itself once the pending reply
* completes in the background.
* - Stopped by [NotificationHelper.stop] when the app returns to the foreground, or
* retired automatically upon auth expiry or terminal disconnection.
*
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
@@ -68,20 +68,36 @@ class ChatNotificationService : Service() {
private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle()
/**
* Turn-row correlation used when a reply completes in the background.
* Injectable so the resolution path can be exercised without REST.
*/
internal var turnRowResolver: TurnRowResolver = defaultTurnRowResolver
@Volatile
internal var activeServiceInstance: ChatNotificationService? = null
fun setAppForeground(foreground: Boolean) {
isAppInForeground.set(foreground)
}
fun isAppInForeground(): Boolean = isAppInForeground.get()
fun updateForegroundNotification(state: BackgroundNotificationState) {
activeServiceInstance?.updateNotification(state)
}
}
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var eventCollector: Job? = null
private var statusCollector: Job? = null
override fun onCreate() {
super.onCreate()
activeServiceInstance = this
createNotificationChannels()
startEventCollection()
startStatusCollection()
}
private fun startEventCollection() {
@@ -100,10 +116,23 @@ class ChatNotificationService : Service() {
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(
preview,
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
showReplyNotification(
text = preview,
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
// normal, safe outcome: the notification is then
// never auto-dismissed from REST hydration, which
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
coalesceTurnRow(targetSessionId, event.text),
)
// The wait is over — retire the foreground
// service. The reply notification above
@@ -113,7 +142,7 @@ class ChatNotificationService : Service() {
// service is not restarted on the next
// ON_STOP (issue #794).
// A delayed completion must not retire a newer turn/start.
if (!HermesWsClient.pendingReply) lifecycle.complete(generation)
BackgroundConnectionController.default.onReplyCompleted(generation)
}
is WsEvent.ClarifyRequest -> {
@@ -156,9 +185,37 @@ class ChatNotificationService : Service() {
}
}
/**
* Names the exact REST row this completion produced, using the turn
* boundary armed before the prompt was submitted. Any failure to prove that
* identity returns null — the notification is still posted, it just will not
* be auto-dismissed from REST hydration.
*/
private suspend fun coalesceTurnRow(
sessionId: String?,
completionText: String,
): Int? {
if (sessionId.isNullOrBlank()) return null
return try {
correlateCompletedTurnRow(
scopeId = correlationScopeId(),
sessionId = sessionId,
completionText = completionText,
resolver = turnRowResolver,
)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
private fun showReplyNotification(
text: String,
sessionId: String?,
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
) {
val builder =
NotificationCompat
@@ -172,6 +229,39 @@ class ChatNotificationService : Service() {
.setAutoCancel(true)
.setContentIntent(buildContentIntent(sessionId))
var replyGeneration: Long? = null
if (isReplyMessage && !sessionId.isNullOrBlank() && !completionId.isNullOrBlank()) {
val scopeId = correlationScopeId()
val generation =
ReplyNotificationTracker.registerPendingReply(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
textSnippet = text,
serverMessageId = serverMessageId,
)
replyGeneration = generation
builder.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLY)
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, text)
putLong(ReplyNotificationTracker.EXTRA_GENERATION, generation)
serverMessageId?.let {
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
}
},
)
} else {
builder.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_ACTION)
},
)
}
if (!sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput =
@@ -183,6 +273,11 @@ class ChatNotificationService : Service() {
val replyIntent =
Intent(this, NotificationReplyReceiver::class.java).apply {
action = "$packageName.ACTION_NOTIFICATION_REPLY"
component =
android.content.ComponentName(
this@ChatNotificationService,
NotificationReplyReceiver::class.java,
)
setPackage(packageName)
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
@@ -207,19 +302,32 @@ class ChatNotificationService : Service() {
builder.addAction(action)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(PENDING_NOTIFICATION_ID, builder.build())
val notification = builder.build()
if (replyGeneration != null) {
ReplyNotificationTracker.postReplyNotification(
context = this,
notification = notification,
generation = replyGeneration,
)
} else {
ReplyNotificationTracker.postActionNotification(
context = this,
notification = notification,
)
}
}
private fun buildContentIntent(sessionId: String?): PendingIntent {
val intent =
Intent(this, MainActivity::class.java).apply {
action = MainActivity.ACTION_OPEN_CHAT_FROM_NOTIFICATION
component = android.content.ComponentName(this@ChatNotificationService, MainActivity::class.java)
setPackage(packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (!sessionId.isNullOrBlank()) {
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
}
if (!sessionId.isNullOrBlank()) {
intent.putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
return PendingIntent.getActivity(
this,
sessionId?.hashCode() ?: 0,
@@ -269,10 +377,13 @@ class ChatNotificationService : Service() {
flags: Int,
startId: Int,
): Int {
val initialDecision =
BackgroundConnectionPolicy.evaluate(BackgroundConnectionController.defaultSnapshot(isDeparting = true))
val initialText = resolveNotificationText(initialDecision.notificationState)
lifecycle.onStart(
owner = this,
promote = {
startForeground(NOTIFICATION_ID, buildForegroundNotification(getString(R.string.notif_waiting_replies)))
startForeground(NOTIFICATION_ID, buildForegroundNotification(initialText))
},
// Do not remove foreground status before Android accepts retirement:
// a newer start may already be queued, but not delivered to us yet.
@@ -284,11 +395,52 @@ class ChatNotificationService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
if (activeServiceInstance === this) {
activeServiceInstance = null
}
lifecycle.onDestroyed(this)
eventCollector?.cancel()
statusCollector?.cancel()
serviceScope.cancel()
super.onDestroy()
}
private fun startStatusCollection() {
statusCollector =
serviceScope.launch {
launch {
NetworkMonitor.networkChanges.collect {
if (!isAppInForeground.get()) {
BackgroundConnectionController.default.reconcileState()
}
}
}
launch {
HermesWsClient.connectionStatus.collect {
if (!isAppInForeground.get()) {
BackgroundConnectionController.default.reconcileState()
}
}
}
}
}
internal fun updateNotification(state: BackgroundNotificationState) {
if (isAppInForeground.get()) return
val text = resolveNotificationText(state)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
manager.notify(NOTIFICATION_ID, buildForegroundNotification(text))
}
private fun resolveNotificationText(state: BackgroundNotificationState): String =
when (state) {
BackgroundNotificationState.WaitingForNetwork -> getString(R.string.notif_waiting_network)
BackgroundNotificationState.Connecting -> getString(R.string.notif_connecting)
BackgroundNotificationState.Reconnecting -> getString(R.string.notif_reconnecting)
BackgroundNotificationState.WaitingForReplies -> getString(R.string.notif_waiting_replies)
BackgroundNotificationState.ConnectedInBackground -> getString(R.string.notif_connected_in_background)
BackgroundNotificationState.None -> getString(R.string.notif_waiting_replies)
}
}
/**
@@ -296,14 +448,8 @@ class ChatNotificationService : Service() {
*/
object NotificationHelper {
fun start(context: Context) {
if (AuthManager.getToken().isNullOrBlank()) return
// Only run the foreground service while a reply is actually pending
// (issue #794) — otherwise the mandatory persistent "Waiting for
// Hermes replies" notification appears on every background even
// when nothing is in flight.
if (!HermesWsClient.pendingReply) return
val intent = Intent(context, ChatNotificationService::class.java)
ChatNotificationService.lifecycle.start {
BackgroundConnectionController.default.onAppPause {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@@ -313,9 +459,7 @@ object NotificationHelper {
}
fun stop(context: Context) {
// Rotation can resume the activity before the queued service is created.
// Direct stopService here crashes Android while foreground promotion is owed.
ChatNotificationService.lifecycle.stop()
BackgroundConnectionController.default.onAppResume()
}
fun setAppForeground(
@@ -324,6 +468,9 @@ object NotificationHelper {
) {
ChatNotificationService.setAppForeground(foreground)
HermesWsClient.setAppForeground(foreground)
if (!foreground) {
BackgroundConnectionController.default.reconcileState()
}
}
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
@@ -24,6 +24,13 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L
/**
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
* the probe sits on the reply path, so a slow or unreachable gateway must
* cost the user's reply only a sliver of its 5s deadline.
*/
private const val BOUNDARY_TIMEOUT_MS = 250L
}
// Reusable scope for async reply processing — avoids creating a new
@@ -50,7 +57,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
.setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
},
).build()
override fun onReceive(
context: Context,
@@ -78,6 +89,17 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
return@withContext
}
// Mobile-originated follow-up turn: arm its durable
// boundary BEFORE prompt.submit. Budgeted tighter
// than the chat composer's — this receiver has its
// own 5s deadline and a failed capture must only
// make the reply uncorrelatable, never lose it.
captureTurnBoundary(
scopeId = correlationScopeId(),
sessionId = sessionId,
timeoutMs = BOUNDARY_TIMEOUT_MS,
)
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
@@ -102,8 +124,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
dao.upsert(entity)
val repliedNotification = buildReplyNotification(context)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)
ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
// The follow-up turn is now pending — keep the
// foreground service alive if it retired after the
@@ -0,0 +1,302 @@
package com.m57.hermescontrol.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.util.concurrent.atomic.AtomicLong
data class ReplyNotificationTarget(
val scopeId: String,
val sessionId: String,
val completionId: String,
val generation: Long,
val textSnippet: String = "",
val timestamp: Long = System.currentTimeMillis(),
/**
* Durable REST `messages.id` of the row this reply produced. Resolved by
* [TurnCorrelationTracker] from the boundary armed before the prompt was
* submitted — never taken from the completion event, text, or a clock. Null
* means the turn could not be correlated, and then hydration must not touch
* this notification at all (fail closed).
*/
val serverMessageId: Int? = null,
) {
fun matches(
candidateScopeId: String?,
candidateSessionId: String?,
candidateCompletionId: String?,
): Boolean {
if (scopeId.isNotBlank() && scopeId != candidateScopeId) {
return false
}
if (candidateSessionId.isNullOrBlank() || sessionId != candidateSessionId) {
return false
}
if (candidateCompletionId.isNullOrBlank() || completionId.isBlank()) {
return false
}
return candidateCompletionId == completionId
}
}
internal data class ActiveReplyInfo(
val id: Int,
val kind: String?,
val scopeId: String?,
val sessionId: String?,
val completionId: String?,
val textSnippet: String?,
val generation: Long,
val timestamp: Long,
val serverMessageId: Int? = null,
)
object ReplyNotificationTracker {
const val EXTRA_NOTIF_KIND = "hermes_notif_kind"
const val EXTRA_SCOPE_ID = "hermes_scope_id"
const val EXTRA_SESSION_ID = "hermes_session_id"
const val EXTRA_COMPLETION_ID = "hermes_completion_id"
const val EXTRA_TEXT_SNIPPET = "hermes_text_snippet"
const val EXTRA_GENERATION = "hermes_generation"
const val EXTRA_SERVER_MESSAGE_ID = "hermes_server_message_id"
const val KIND_REPLY = "reply"
const val KIND_ACTION = "action"
const val KIND_REPLIED = "replied"
private val generationCounter = AtomicLong(0L)
private val tombstoneGeneration = AtomicLong(0L)
@Volatile
private var activeTarget: ReplyNotificationTarget? = null
private val defaultActiveNotificationProvider: (Context) -> ActiveReplyInfo? = { context ->
runCatching {
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
val sbn =
manager?.activeNotifications?.firstOrNull {
it.id == ChatNotificationService.PENDING_NOTIFICATION_ID
}
if (sbn != null) {
val extras = sbn.notification?.extras
ActiveReplyInfo(
id = sbn.id,
kind = extras?.getString(EXTRA_NOTIF_KIND),
scopeId = extras?.getString(EXTRA_SCOPE_ID),
sessionId = extras?.getString(EXTRA_SESSION_ID),
completionId = extras?.getString(EXTRA_COMPLETION_ID),
textSnippet = extras?.getString(EXTRA_TEXT_SNIPPET),
generation = extras?.getLong(EXTRA_GENERATION, 0L) ?: 0L,
timestamp = sbn.postTime,
serverMessageId = extras?.getInt(EXTRA_SERVER_MESSAGE_ID, -1)?.takeIf { it >= 0 },
)
} else {
null
}
}.getOrNull()
}
internal var activeNotificationProvider: (Context) -> ActiveReplyInfo? = defaultActiveNotificationProvider
fun nextGeneration(): Long {
val floor = maxOf(generationCounter.get(), tombstoneGeneration.get())
return generationCounter.updateAndGet { maxOf(it, floor) + 1 }
}
@Synchronized
fun registerPendingReply(
scopeId: String,
sessionId: String,
completionId: String,
textSnippet: String,
timestamp: Long = System.currentTimeMillis(),
serverMessageId: Int? = null,
): Long {
val generation = nextGeneration()
activeTarget =
ReplyNotificationTarget(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
generation = generation,
textSnippet = textSnippet,
timestamp = timestamp,
serverMessageId = serverMessageId,
)
return generation
}
@Synchronized
fun postReplyNotification(
context: Context,
notification: Notification,
generation: Long,
): Boolean {
if (generation <= tombstoneGeneration.get()) {
return false
}
val current = activeTarget
if (current != null && current.generation > generation) {
return false
}
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun postActionNotification(
context: Context,
notification: Notification,
): Boolean {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun postRepliedNotification(
context: Context,
notification: Notification,
): Boolean {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun onReplyNotificationPosted(
scopeId: String,
sessionId: String,
completionId: String,
textSnippet: String,
generation: Long,
timestamp: Long = System.currentTimeMillis(),
serverMessageId: Int? = null,
) {
if (generation <= tombstoneGeneration.get()) return
generationCounter.updateAndGet { maxOf(it, generation) }
activeTarget =
ReplyNotificationTarget(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
generation = generation,
textSnippet = textSnippet,
timestamp = timestamp,
serverMessageId = serverMessageId,
)
}
@Synchronized
fun onNonReplyNotificationPosted() {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
}
@Synchronized
fun getActiveTarget(context: Context? = null): ReplyNotificationTarget? {
val inMemory = activeTarget
if (inMemory != null) return inMemory
return if (context != null) resolveTarget(context) else null
}
@Synchronized
fun onMessageVisible(
context: Context,
scopeId: String?,
sessionId: String?,
completionId: String?,
): Boolean {
if (sessionId.isNullOrBlank()) return false
val target = resolveTarget(context) ?: return false
if (target.matches(scopeId, sessionId, completionId)) {
return cancelReplyNotification(context, target.generation)
}
return false
}
@Synchronized
fun cancelReplyNotification(
context: Context,
expectedGeneration: Long,
): Boolean {
val target = resolveTarget(context)
if (target != null && target.generation > expectedGeneration) {
return false
}
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
val activeInfo = activeNotificationProvider(context)
if (activeInfo != null) {
if (activeInfo.kind != KIND_REPLY) {
activeTarget = null
return false
}
if (activeInfo.generation <= tombstoneGeneration.get()) {
activeTarget = null
return false
}
if (activeInfo.generation > expectedGeneration) {
return false
}
}
tombstoneGeneration.updateAndGet { maxOf(it, expectedGeneration) }
manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID)
activeTarget = null
return true
}
private fun resolveTarget(context: Context): ReplyNotificationTarget? {
val inMemory = activeTarget
if (inMemory != null) return inMemory
val activeInfo = activeNotificationProvider(context) ?: return null
if (activeInfo.kind != KIND_REPLY) return null
generationCounter.updateAndGet { maxOf(it, activeInfo.generation) }
if (activeInfo.generation <= tombstoneGeneration.get()) return null
val sessionId = activeInfo.sessionId.orEmpty()
if (sessionId.isBlank()) return null
return ReplyNotificationTarget(
scopeId = activeInfo.scopeId.orEmpty(),
sessionId = sessionId,
completionId = activeInfo.completionId.orEmpty(),
generation = activeInfo.generation,
textSnippet = activeInfo.textSnippet.orEmpty(),
timestamp = activeInfo.timestamp,
serverMessageId = activeInfo.serverMessageId,
).also { activeTarget = it }
}
@VisibleForTesting
fun resetForTest() {
activeTarget = null
generationCounter.set(0L)
tombstoneGeneration.set(0L)
activeNotificationProvider = defaultActiveNotificationProvider
}
@VisibleForTesting
fun setTargetForTest(target: ReplyNotificationTarget?) {
activeTarget = target
}
}
@@ -0,0 +1,384 @@
package com.m57.hermescontrol.notification
import android.content.Context
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
/**
* Durable lower bound for one mobile-originated turn: the highest REST
* transcript row id that existed immediately BEFORE `prompt.submit` left the
* device.
*
* The id is the gateway's AUTOINCREMENT `messages.id`, so every row persisted
* by the turn itself is strictly greater than [beforeMessageId]. That gives the
* notification layer a turn-scoped window it can search without text
* heuristics, row ordering, or cross-machine clocks.
*
* Captured before the prompt is submitted, never after: a fast turn can persist
* its assistant row before a post-submit snapshot request finishes, which would
* place the reply inside the "before" snapshot (and a lower bound can then never
* exclude it).
*/
data class TurnBoundary(
val scopeId: String,
val sessionId: String,
val beforeMessageId: Int,
/** Monotonic arm order — a stale clear must not remove a newer turn's boundary. */
val generation: Long,
val armedAt: Long,
) {
fun matchesKey(
otherScopeId: String,
otherSessionId: String,
): Boolean = scopeId == otherScopeId && sessionId == otherSessionId
}
/** Persistence seam so the tracker can be unit-tested without an Android context. */
internal interface TurnBoundaryStore {
fun read(): List<TurnBoundary>
fun write(boundaries: List<TurnBoundary>)
}
/**
* SharedPreferences-backed store — a handful of tiny `scope|session -> state`
* rows, so a process death during a running turn does not lose the boundary.
*/
internal class PrefsTurnBoundaryStore(
context: Context,
) : TurnBoundaryStore {
private val prefs =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun read(): List<TurnBoundary> =
prefs.all.mapNotNull { (key, value) -> decodeBoundary(key, value as? String) }
override fun write(boundaries: List<TurnBoundary>) {
val editor = prefs.edit().clear()
boundaries.forEach { editor.putString(encodeKey(it.scopeId, it.sessionId), encodeValue(it)) }
editor.apply()
}
internal companion object {
const val PREFS_NAME = "hermes_turn_boundaries"
private const val KEY_SEPARATOR = '|'
fun encodeKey(
scopeId: String,
sessionId: String,
): String = "$scopeId$KEY_SEPARATOR$sessionId"
fun encodeValue(boundary: TurnBoundary): String =
"${boundary.beforeMessageId}:${boundary.generation}:${boundary.armedAt}"
fun decodeBoundary(
key: String,
value: String?,
): TurnBoundary? {
val separator = key.indexOf(KEY_SEPARATOR)
if (separator <= 0 || value == null) return null
val parts = value.split(':')
if (parts.size != 3) return null
val beforeMessageId = parts[0].toIntOrNull() ?: return null
val generation = parts[1].toLongOrNull() ?: return null
val armedAt = parts[2].toLongOrNull() ?: return null
return TurnBoundary(
scopeId = key.substring(0, separator),
sessionId = key.substring(separator + 1),
beforeMessageId = beforeMessageId,
generation = generation,
armedAt = armedAt,
)
}
}
}
/**
* Turn-boundary registry for reply-notification correlation.
*
* A boundary is armed per (profile scope, stored session id) immediately before
* a mobile-originated prompt is submitted, and consumed when a completion for
* that session is handled. Sessions that never had a boundary (desktop/TUI
* turns, another connected client, a failed capture, queued or group-chat
* turns) simply fail closed: the reply notification is posted without a durable
* row id and therefore is never auto-dismissed from REST hydration alone.
*
* Scope isolation is structural — a boundary is only visible to the exact
* profile that armed it, so switching profiles makes the old boundary
* unreachable instead of resolvable against the wrong namespace.
*/
object TurnCorrelationTracker {
/** Bound the registry: stale turns must not accumulate across app restarts. */
private const val MAX_ENTRIES = 32
/** A boundary older than this belongs to a turn nobody is waiting on anymore. */
private const val MAX_AGE_MS = 6 * 60 * 60 * 1000L
private val generationCounter = AtomicLong(0L)
private val lock = Any()
@Volatile
private var cache: List<TurnBoundary> = emptyList()
@Volatile
private var store: TurnBoundaryStore? = null
/** Idempotent — called once from [com.m57.hermescontrol.HermesControlApp]. */
fun attach(context: Context) {
synchronized(lock) {
if (store != null) return
// Never let persistence break app startup: an unusable context
// degrades to in-memory boundaries.
val prefsStore = runCatching { PrefsTurnBoundaryStore(context) }.getOrNull() ?: return
store = prefsStore
val restored = prune(runCatching { prefsStore.read() }.getOrDefault(emptyList()))
cache = restored
reserveGenerationFloor(restored)
}
}
/** Test seam: swap the persistent store (or detach with `null`). */
@VisibleForTesting
internal fun attachStore(store: TurnBoundaryStore?) {
synchronized(lock) {
this.store = store
val restored = store?.let { prune(it.read()) } ?: emptyList()
cache = restored
reserveGenerationFloor(restored)
}
}
/**
* Records the pre-submit high-watermark. Returns the armed boundary, or null
* when the identity is unusable (blank scope/session, no rows and no
* confirmed read).
*/
fun armBoundary(
scopeId: String,
sessionId: String,
beforeMessageId: Int,
now: Long = System.currentTimeMillis(),
): TurnBoundary? {
if (scopeId.isBlank() || sessionId.isBlank() || beforeMessageId < 0) return null
val boundary =
TurnBoundary(
scopeId = scopeId,
sessionId = sessionId,
beforeMessageId = beforeMessageId,
generation = generationCounter.incrementAndGet(),
armedAt = now,
)
synchronized(lock) {
val next = prune(cache.filterNot { it.matchesKey(scopeId, sessionId) } + boundary, now = now)
cache = next
runCatching { store?.write(next) }
}
return boundary
}
fun boundaryFor(
scopeId: String,
sessionId: String,
now: Long = System.currentTimeMillis(),
): TurnBoundary? {
if (scopeId.isBlank() || sessionId.isBlank()) return null
return synchronized(lock) {
cache.firstOrNull { it.matchesKey(scopeId, sessionId) && isFresh(it, now) }
}
}
/**
* Consumes a boundary. [generation] guards the race where a newer turn armed
* a replacement while an older completion was still being correlated.
*/
fun clearBoundary(
scopeId: String,
sessionId: String,
generation: Long? = null,
): Boolean {
synchronized(lock) {
val existing = cache.firstOrNull { it.matchesKey(scopeId, sessionId) } ?: return false
if (generation != null && existing.generation != generation) return false
val next = cache.filterNot { it === existing }
cache = next
runCatching { store?.write(next) }
return true
}
}
private fun prune(
boundaries: List<TurnBoundary>,
now: Long = System.currentTimeMillis(),
): List<TurnBoundary> =
boundaries
.filter { isFresh(it, now) }
.sortedByDescending { it.armedAt }
.take(MAX_ENTRIES)
/**
* Keeps generations unique across process death.
*
* The counter lives in memory only, so after a restart a restored boundary
* (generation N) and the next armed boundary would both be N — and the
* stale-clear guard, which exists precisely to stop an old completion's
* clear from removing a newer turn's boundary, would stop protecting
* anything. Lifting the counter above the recovered generations keeps the
* guard meaningful.
*/
private fun reserveGenerationFloor(restored: List<TurnBoundary>) {
val maxRestored = restored.maxOfOrNull { it.generation } ?: 0L
generationCounter.updateAndGet { maxOf(it, maxRestored) }
}
private fun isFresh(
boundary: TurnBoundary,
now: Long,
): Boolean = now - boundary.armedAt <= MAX_AGE_MS
@VisibleForTesting
internal fun resetForTest() {
synchronized(lock) {
cache = emptyList()
store = null
generationCounter.set(0L)
}
}
}
/** How long the pre-submit boundary read may take before the turn goes uncorrelatable. */
private const val TURN_BOUNDARY_TIMEOUT_MS = 1_500L
/**
* Correlation scope for the active connection profile.
*
* `AuthManager.activeProfileId` stays null until a server profile is explicitly
* selected, and the rest of AuthManager treats that as
* [AuthManager.DEFAULT_PROFILE_ID] (see `currentDataScope()`). Collapsing
* null/blank here gives this feature ONE scope identity — a raw `.orEmpty()`
* would reject the default profile, so a normal install would arm no boundary,
* resolve no row, and silently never auto-dismiss a reply notification.
*
* The arming side (prompt submit), the resolution side (completion), the
* notification extras, and the chat viewport observer must ALL use this, or the
* two halves disagree on the scope and dismissal stops working.
*/
internal fun correlationScopeId(): String =
AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
?: AuthManager.DEFAULT_PROFILE_ID
/** Rows scanned when correlating a completed turn (newest-first page). */
private const val TURN_ROW_SCAN_LIMIT = 20
/**
* Reads the durable REST transcript high-watermark for [sessionId] and arms a
* boundary for it. Call immediately BEFORE submitting the prompt.
*
* Deliberately does not send a `profile` query param: the boundary ids are later
* compared against rows hydrated by the chat screen, which fetches this
* endpoint without a profile. Keeping both reads in the same namespace is what
* makes the id comparison meaningful.
*
* Returns false on any failure (including a gateway that does not confirm
* `order=latest`) — the caller sends its prompt anyway and the turn simply
* becomes uncorrelatable.
*/
internal suspend fun captureTurnBoundary(
scopeId: String,
sessionId: String,
timeoutMs: Long = TURN_BOUNDARY_TIMEOUT_MS,
): Boolean {
if (scopeId.isBlank() || sessionId.isBlank()) return false
val maxMessageId =
try {
withTimeoutOrNull(timeoutMs) { fetchBoundaryTailId(sessionId) }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Total by contract: this probe is best-effort identity for a
// notification convenience and must NEVER fail the caller's turn.
// Swallowing Throwable (not just Exception) is deliberate — a
// gateway-layer Error (uninitialised client, NoClassDefFound) used to
// escape past the caller's `catch (Exception)` and get thrown inside
// a background scope, which aborted the send and leaked as an
// uncaught exception attributed to an unrelated later test.
null
} ?: return false
return TurnCorrelationTracker.armBoundary(scopeId, sessionId, maxMessageId) != null
}
/**
* Newest row id of the transcript, or null when the read cannot be trusted.
*
* `pagination` is only returned by gateways that honoured `order=latest`; a
* backend that ignores the param would answer a `limit=1` probe with the OLDEST
* row, producing a boundary that is too low — a lower bound that is too low is
* exactly what lets a historical duplicate win. Absent proof, fail closed.
*/
private suspend fun fetchBoundaryTailId(sessionId: String): Int? {
val result =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = sessionId,
limit = 1,
offset = 0,
includeCompacted = true,
order = "latest",
)
}
val body = (result as? NetworkResult.Success)?.data ?: return null
// The whole safety property rests on this single row being the HIGHEST id in
// the transcript. Require the gateway to confirm it honoured order=latest
// (same proof the chat hydration uses) — a backend that ignores the param
// answers with the OLDEST row, and a lower bound that is too low is exactly
// what lets a historical duplicate win.
if (body.pagination?.order != "latest") return null
return body.messages.mapNotNull { it.id }.maxOrNull() ?: 0
}
/** Newest-first REST page used to correlate a completed turn. */
internal suspend fun fetchTurnRows(sessionId: String): List<SessionMessage>? {
val result =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = sessionId,
limit = TURN_ROW_SCAN_LIMIT,
offset = 0,
includeCompacted = true,
order = "latest",
)
}
return (result as? NetworkResult.Success)?.data?.messages
}
/**
* Resolves the exact REST row persisted by the turn that just completed, given
* the boundary armed before that turn was submitted.
*
* The boundary is consumed either way in `finally`: the turn it describes is
* over, and leaving it armed would let the next completion bind to a row above
* a stale lower bound.
*/
internal suspend fun correlateCompletedTurnRow(
scopeId: String,
sessionId: String,
completionText: String,
resolver: TurnRowResolver = defaultTurnRowResolver,
): Int? {
val boundary = TurnCorrelationTracker.boundaryFor(scopeId, sessionId) ?: return null
return try {
resolver.resolve(sessionId, boundary, completionText)
} finally {
TurnCorrelationTracker.clearBoundary(scopeId, sessionId, boundary.generation)
}
}
/** Production resolver — reads the newest REST page for the session. */
internal val defaultTurnRowResolver =
TurnRowResolver(fetchLatestRows = { sessionId -> fetchTurnRows(sessionId) })
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.notification
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.ui.chat.HostMediaExtractor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Resolves the one persisted REST assistant row produced by a completed
* mobile-originated turn.
*
* Design rules (all of them are load-bearing):
* - Only rows strictly above [TurnBoundary.beforeMessageId] are candidates, so
* historical duplicates can never win.
* - The FULL completion text is compared, never the 100-character notification
* preview.
* - Exactly one candidate resolves. Zero candidates retry (the row may not be
* persisted yet). More than one candidate is ambiguous and fails closed —
* `firstOrNull`, `lastOrNull`, `maxBy` and "newest match" are all guessing.
* - Never compares Android notification time with server timestamps.
*/
internal class TurnRowResolver(
private val fetchLatestRows: suspend (String) -> List<SessionMessage>?,
/** Attempt delays; the first attempt runs immediately. */
private val retryDelaysMs: List<Long> = listOf(0L, 200L, 400L),
) {
suspend fun resolve(
sessionId: String,
boundary: TurnBoundary,
completionText: String,
): Int? {
val wanted = assistantTextKey(completionText)
if (wanted.isEmpty) return null
if (sessionId.isBlank()) return null
for ((attempt, delayMs) in retryDelaysMs.withIndex()) {
if (attempt > 0) delay(delayMs)
val rows = fetchRows(sessionId) ?: continue
val candidates =
rows.mapNotNull { row ->
val id = row.id ?: return@mapNotNull null
if (id <= boundary.beforeMessageId) return@mapNotNull null
if (!row.role.equals("assistant", ignoreCase = true)) return@mapNotNull null
if (assistantTextKey(row.contentText) != wanted) return@mapNotNull null
id
}
if (candidates.size == 1) return candidates.single()
// Two rows above the boundary match this completion: the turn's own
// row cannot be told apart from the other. Leave the notification
// active rather than bind it to a coin flip.
if (candidates.size > 1) return null
}
return null
}
private suspend fun fetchRows(sessionId: String): List<SessionMessage>? =
try {
fetchLatestRows(sessionId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
/**
* Normalized comparison key for an assistant row.
*
* Text replies compare their trimmed full text. `MEDIA:` replies compare the
* directive-stripped text PLUS the extracted host paths, using the same
* canonicalization the chat mapper applies before rendering — so a MEDIA row
* and the matching completion agree on both halves, and a MEDIA-only reply
* (which strips to an empty string) still compares by its paths instead of
* degenerating into "any assistant row".
*/
internal data class AssistantTextKey(
val text: String,
val media: List<String>,
) {
val isEmpty: Boolean
get() = text.isBlank() && media.isEmpty()
}
private const val MEDIA_DIRECTIVE = "MEDIA:"
internal fun assistantTextKey(raw: String): AssistantTextKey {
val trimmed = raw.trim()
if (!trimmed.contains(MEDIA_DIRECTIVE)) return AssistantTextKey(trimmed, emptyList())
return AssistantTextKey(
text = HostMediaExtractor.strip(trimmed).trim(),
media = HostMediaExtractor.extract(trimmed).map { it.path },
)
}
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.ui.common.EmptyState
@@ -72,6 +73,7 @@ fun AchievementsScreen(
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showResetDialog by remember { mutableStateOf(false) }
@@ -92,7 +94,8 @@ fun AchievementsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadAchievements()
viewModel.loadRecentUnlocks()
}
@@ -129,7 +132,10 @@ fun AchievementsScreen(
title = { Text(stringResource(R.string.screen_achievements)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() },
onRefresh = {
viewModel.loadAchievements(forceRefresh = true)
viewModel.loadRecentUnlocks()
},
modifier = modifier,
actions = {
Box {
@@ -1,12 +1,16 @@
package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -46,9 +50,53 @@ class AchievementsViewModel :
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
private val api get() = ApiClient.hermesApi
private val achievementsCache = SwrCache<String, AchievementsResponse>()
fun loadAchievements() {
safeLaunchLoad(
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
achievements = emptyList(),
unlockedCount = 0,
discoveredCount = 0,
secretCount = 0,
totalCount = 0,
categories = emptyList(),
recentUnlocks = emptyList(),
errorMessage = null,
)
}
}
fun loadAchievements(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = achievementsCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
val categories =
data.achievements
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
_uiState.update {
it.copy(
isLoading = false,
achievements = data.achievements.orEmpty(),
unlockedCount = data.unlockedCount,
discoveredCount = data.discoveredCount,
secretCount = data.secretCount,
totalCount = data.totalCount,
isStale = data.isStale,
generatedAt = data.generatedAt,
scanState = data.scanMeta?.status?.state ?: "idle",
scanLastError = data.scanMeta?.status?.lastError,
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
categories = categories,
errorMessage = null,
)
}
},
apiCall = { safeApiCall { api.getAchievements() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -100,6 +148,7 @@ class AchievementsViewModel :
}
fun rescan() {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
safeLaunchLoad(
apiCall = { safeApiCall { api.rescanAchievements() } },
onStart = {
@@ -111,6 +160,12 @@ class AchievementsViewModel :
?.mapNotNull { it.category }
?.distinct()
?.sorted() ?: emptyList()
// A successful rescan is a mutation: publish the fresh payload into the
// scoped cache, otherwise the next SWR revisit replays the pre-rescan list.
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope) {
achievementsCache.put(requestScope.scopedKey("default"), data)
}
_uiState.update {
it.copy(
isRescanning = false,
@@ -155,7 +210,7 @@ class AchievementsViewModel :
toastMessage = "Achievement state reset",
)
}
loadAchievements()
loadAchievements(forceRefresh = true)
},
onError = { errorMsg ->
_uiState.update {
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -235,68 +237,72 @@ class BotsViewModel(
}
}
viewModelScope.launch(ioDispatcher) {
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
coroutineScope {
val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
}
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val activeResult = activeDeferred.await()
val profilesResult =
if (profilesWithMeta != null) {
null
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesWithMeta != null) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
} else {
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
}
}
}
}
@@ -696,6 +696,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
// Bot-group turns are orchestrated across per-bot sessions that
// are created and swapped mid-flight, so no clean pre-submit
// turn boundary can be proven for them. They stay uncorrelated
// (reply notifications from group turns are never auto-dismissed
// from REST hydration) instead of borrowing another turn's bound.
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
@@ -593,16 +593,20 @@ internal fun InlineAttachment(
val isSaving = savingPath != null && savingPath == attachmentPath
val isOpening = openingPath != null && openingPath == attachmentPath
val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isVideo) {
var showVideoDialog by remember { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
videoUri = attachment.uri,
onFullScreenClick = { showVideoDialog = true },
if (attachment.isVideo || attachment.isAudio) {
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
uri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onFullScreenClick = { showMediaDialog = true },
)
if (showVideoDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
videoUri = attachment.uri,
onDismissRequest = { showVideoDialog = false },
if (showMediaDialog) {
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
mediaUri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onDismissRequest = { showMediaDialog = false },
)
}
} else if (attachment.isImage) {
@@ -24,7 +24,8 @@ object ChatInputPolicy {
text: String,
pendingAttachments: List<Any>,
isConnected: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected
isSessionReady: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected && isSessionReady
/**
* Whether the input placeholder should read "queued" rather than the plain
@@ -85,6 +85,8 @@ data class ChatMessage(
val tokenCount: Int? = null,
/** Tokens per second generation speed (persisted to SQLite). */
val tps: Double? = null,
/** Stable completion identity correlating with reply notifications for read tracking. */
val completionId: String? = null,
)
/**
@@ -11,14 +11,16 @@ import com.m57.hermescontrol.data.local.toUiModel
* UI state management and WebSocket event handling.
*/
open class ChatPersistenceRepository(
private val dao: ChatMessageDao,
private val daoProvider: suspend () -> ChatMessageDao,
) {
constructor(dao: ChatMessageDao) : this({ dao })
/** Persist a single message for the given session. */
suspend fun persistMessage(
message: ChatMessage,
sessionId: String,
) {
dao.upsert(message.toEntity(sessionId))
daoProvider().upsert(message.toEntity(sessionId))
}
/** Persist multiple messages in one transaction. */
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
sessionId: String,
) {
val entities = messages.map { it.toEntity(sessionId) }
dao.upsertAll(entities)
daoProvider().upsertAll(entities)
}
/** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
/** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) {
dao.deleteMessagesForSession(sessionId)
daoProvider().deleteMessagesForSession(sessionId)
}
}
@@ -155,6 +155,7 @@ fun ChatScreen(
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
@@ -426,6 +427,20 @@ fun ChatScreen(
context = context,
)
val isChatContentReadable =
!showReloginDialog &&
!state.updateConfirmOpen &&
!actionProgressState.visible &&
!state.showModelPicker &&
state.modelSwitchConfirmMessage == null &&
state.sudoPrompt == null &&
state.secretPrompt == null &&
!(showContextSheet && state.contextBreakdown != null) &&
!showSubagentInspectionSheet &&
state.btwState == null &&
viewingImage == null &&
!connectorsState.isVisible
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
sessionId = sessionId,
@@ -442,6 +457,7 @@ fun ChatScreen(
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
isOverlayActive = !isChatContentReadable,
)
HermesScaffold(
@@ -503,22 +519,6 @@ fun ChatScreen(
}
},
actions = {
// Search toggle
IconButton(onClick = { viewModel.toggleSearch() }) {
Icon(
imageVector =
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription =
if (searchState.isActive) {
stringResource(
R.string.chat_action_close_search,
)
} else {
stringResource(R.string.chat_action_search)
},
)
}
IconButton(onClick = { viewModel.createNewSession() }) {
Icon(
imageVector = Icons.Filled.Add,
@@ -542,6 +542,33 @@ fun ChatScreen(
expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false },
) {
// Chat search lives in this overflow menu (moved from the top bar)
DropdownMenuItem(
text = {
Text(
stringResource(
if (searchState.isActive) {
R.string.chat_action_close_search
} else {
R.string.chat_action_search
},
),
)
},
leadingIcon = {
Icon(
imageVector =
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
viewModel.toggleSearch()
},
modifier = Modifier.testTag("chat_menu_search"),
)
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
@@ -748,15 +775,18 @@ fun ChatScreen(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
onSend = {
viewModel.sendMessage(inputFieldValue.text)
inputFieldValue = TextFieldValue("")
// Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true)
if (viewModel.sendMessage(inputFieldValue.text)) {
inputFieldValue = TextFieldValue("")
// Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady,
sessionPreparationFailed = state.resumeError != null,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
@@ -776,6 +806,7 @@ fun ChatScreen(
},
// Composer toolbar wiring (PR 1)
currentSessionModel = state.currentSessionModel,
showModelProvider = state.showModelProvider,
reasoningLevel = state.reasoningLevel,
onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.notification.ReplyNotificationTracker
/**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
@@ -20,12 +21,93 @@ internal fun mapServerMessages(
offset: Int,
latestPaging: Boolean,
liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
context: android.content.Context? = null,
): List<ChatMessage> {
val existingReasoningMap =
liveMessages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
val liveByExactId =
liveMessages
.filter { !it.completionId.isNullOrBlank() }
.associateBy { it.id }
val unmappedLiveWsAssistants =
if (isPagingOlder) {
emptyList()
} else {
liveMessages
.filter {
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
!it.completionId.isNullOrBlank()
}
}
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
if (unmappedLiveWsAssistants.isNotEmpty()) {
val remainingWs = unmappedLiveWsAssistants.toMutableList()
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
val m = messages[i]
val r =
when (m.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
if (r != MessageRole.ASSISTANT) continue
val rawContent = m.contentText
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
val restId =
if (latestPaging) {
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
} else {
"rest-$sessionId-${offset + i}"
}
if (liveByExactId.containsKey(restId)) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) {
HostMediaExtractor.strip(rawContent).trim()
} else {
rawContent.trim()
}
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
wsCompletionIdByRestIndex[i] = compId
}
}
}
}
// REST-only recovery is fail-closed: only a durable server row ID may
// transfer notification identity after the WebSocket copy is gone.
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
if (
activeTarget != null &&
activeTarget.sessionId == sessionId &&
activeTarget.serverMessageId != null &&
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
) {
val exactIndex =
messages.indexOfFirst { message ->
message.id == activeTarget.serverMessageId &&
message.role.equals("assistant", ignoreCase = true)
}
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
}
}
}
// Tool rows in the REST transcript carry NO tool name — the live WS
// stream was the only source of `toolName`. Match each REST tool row
// to its WS counterpart by RESULT CONTENT (not position — pagination
@@ -171,6 +253,12 @@ internal fun mapServerMessages(
}
}
val completionId =
if (role == MessageRole.ASSISTANT) {
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
} else {
null
}
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
mapped.add(
ChatMessage(
@@ -184,6 +272,7 @@ internal fun mapServerMessages(
isStreaming = false,
displayKind = msg.display_kind,
tokenCount = tokenCount,
completionId = completionId,
),
)
}
@@ -28,6 +28,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -118,6 +120,7 @@ data class ChatUiState(
val showUserMessageTokens: Boolean = true,
val showAssistantMessageTokens: Boolean = true,
val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
// Per-command usage counts for the slash-autocomplete ranking (issue
@@ -181,6 +184,7 @@ data class ChatUiState(
/** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(),
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
val isSessionReady: Boolean = false,
val resumeError: String? = null,
val isResumeRetrying: Boolean = false,
/** Text staged to prefill the composer (e.g. from /undo). */
@@ -322,9 +326,9 @@ class ChatViewModel(
application: Application,
private val startCleanup: Boolean,
repo: ChatPersistenceRepository =
ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(),
),
ChatPersistenceRepository {
HermesDatabase.get(application).chatMessageDao()
},
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
@@ -587,6 +591,13 @@ class ChatViewModel(
// fire with a stale id and 4001 "session not found";
// handleGatewayReady rebinds it on the re-resume.
runtimeSessionId = null
resumedGeneration = -1L
hydratedGeneration = -1L
activeResumeRequestSequence = ++resumeRequestSequence
activeHydrationRequestSequence = ++hydrationRequestSequence
// Preserve metadata so late results and errors are rejected as stale.
sessionGeneration++
_uiState.update { it.copy(isSessionReady = false) }
// Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending()
@@ -766,43 +777,7 @@ class ChatViewModel(
_streamingState.update { result.streamingState }
// Process side-effects from the reducer
for (effect in result.effects) {
when (effect) {
is ReducerEffect.PersistMessage -> {
viewModelScope.launch(ioDispatcher) {
repo.persistMessage(effect.message, effect.sessionId)
}
}
is ReducerEffect.CreateNewSession -> {
createNewSession()
}
is ReducerEffect.LoadSessions -> {
loadSessions()
}
is ReducerEffect.RefreshSessions -> {
loadSessions()
}
is ReducerEffect.RefreshContextUsage -> {
// Streaming finished — refresh the context meter now rather
// than waiting up to 5s for the next session-sync poll.
viewModelScope.launch { fetchContextUsage() }
}
is ReducerEffect.AttachHostMedia -> {
// Issue #724: turn host-path MEDIA: directives into real
// attachments (images inline, every other file tappable)
// via the gateway /api/files/download endpoint. Works on a
// remote phone too.
viewModelScope.launch(ioDispatcher) {
mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
}
}
}
}
dispatchReducerEffects(result.effects)
// Handle complex events that need ViewModel-specific context
when (event) {
@@ -811,84 +786,7 @@ class ChatViewModel(
}
is WsEvent.SessionInfo -> {
// Session info pushed by backend when config changes
// (model switch, reasoning level, etc.)
val info = event.data
if (info != null) {
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
val terminalBackend = info["terminal_backend"] as? String
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
val fastFlag =
(info["fast"] as? Boolean)
?: (if (serviceTier != null) serviceTier == "priority" else null)
val newModelLabel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model
}
// Issue #817 & #1103: on a REAL model swap the meter's denominator
// still belongs to the old model until the next fetch.
// Check against both lastConfirmedSessionModel and optimisticPreviousModel
// so optimistic model updates in sendSlashModel don't defeat swap detection.
val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
val previousModel =
lastConfirmedSessionModel
?: optimisticPrevious
?: _uiState.value.currentSessionModel
val modelSwapped =
previousModel != null &&
newModelLabel != null &&
!newModelLabel.equals(previousModel, ignoreCase = true)
val initialHydration = previousModel == null && newModelLabel != null
val meterEmpty = _uiState.value.fullContextTokens == null
lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel
if (newModelLabel != null) {
modelSwitchDelegate.onModelConfirmed(newModelLabel)
}
_uiState.update { state ->
state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel,
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend,
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
)
}
modelSwitchDelegate.syncCurrentModelCapabilities()
if (modelSwapped) {
modelGeneration++
contextUsageJob?.cancel()
contextUsageJob = null
// Issue #817 & #1103: after a swap the REST model/info window is
// PROFILE-scoped and may describe the old model (e.g. a
// session-scoped swap) — the meter must not fall back to
// it. Wait for the RPC's live context_max instead; the
// chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
contextUsageJob?.isActive != true
) {
viewModelScope.launch { fetchContextUsage() }
}
// Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen.
val pendingApproval = info["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval,
runtimeSessionId ?: _uiState.value.currentSessionId,
)
}
}
handleSessionInfo(event.data)
}
is WsEvent.MessageToken -> {
@@ -1231,6 +1129,126 @@ class ChatViewModel(
return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId
}
private fun dispatchReducerEffects(effects: List<ReducerEffect>) {
for (effect in effects) {
when (effect) {
is ReducerEffect.PersistMessage -> {
viewModelScope.launch(ioDispatcher) {
repo.persistMessage(effect.message, effect.sessionId)
}
}
is ReducerEffect.CreateNewSession -> {
createNewSession()
}
is ReducerEffect.LoadSessions -> {
loadSessions()
}
is ReducerEffect.RefreshSessions -> {
loadSessions()
}
is ReducerEffect.RefreshContextUsage -> {
// Streaming finished — refresh the context meter now rather
// than waiting up to 5s for the next session-sync poll.
viewModelScope.launch { fetchContextUsage() }
}
is ReducerEffect.AttachHostMedia -> {
// Issue #724: turn host-path MEDIA: directives into real
// attachments (images inline, every other file tappable)
// via the gateway /api/files/download endpoint. Works on a
// remote phone too.
viewModelScope.launch(ioDispatcher) {
mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
}
}
}
}
}
private fun handleSessionInfo(info: Map<String, Any?>?) {
// Session info pushed by backend when config changes
// (model switch, reasoning level, etc.)
if (info != null) {
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
val terminalBackend = info["terminal_backend"] as? String
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
val fastFlag =
(info["fast"] as? Boolean)
?: (if (serviceTier != null) serviceTier == "priority" else null)
val newModelLabel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model
}
// Issue #817 & #1103: on a REAL model swap the meter's denominator
// still belongs to the old model until the next fetch.
// Check against both lastConfirmedSessionModel and optimisticPreviousModel
// so optimistic model updates in sendSlashModel don't defeat swap detection.
val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
val previousModel =
lastConfirmedSessionModel
?: optimisticPrevious
?: _uiState.value.currentSessionModel
val modelSwapped =
previousModel != null &&
newModelLabel != null &&
!newModelLabel.equals(previousModel, ignoreCase = true)
val initialHydration = previousModel == null && newModelLabel != null
val meterEmpty = _uiState.value.fullContextTokens == null
lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel
if (newModelLabel != null) {
modelSwitchDelegate.onModelConfirmed(newModelLabel)
}
_uiState.update { state ->
state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel,
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend,
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
)
}
modelSwitchDelegate.syncCurrentModelCapabilities()
if (modelSwapped) {
modelGeneration++
contextUsageJob?.cancel()
contextUsageJob = null
// Issue #817 & #1103: after a swap the REST model/info window is
// PROFILE-scoped and may describe the old model (e.g. a
// session-scoped swap) — the meter must not fall back to
// it. Wait for the RPC's live context_max instead; the
// chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
contextUsageJob?.isActive != true
) {
viewModelScope.launch { fetchContextUsage() }
}
// Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen.
val pendingApproval = info["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval,
runtimeSessionId ?: _uiState.value.currentSessionId,
)
}
}
}
// ── RPC response handling ────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")
@@ -1243,8 +1261,14 @@ class ChatViewModel(
if (request != null && isStaleSessionRequest(request)) return
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val runtimeId = resultMap["session_id"] as? String ?: return
val resultMap = result as? Map<String, Any?>
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
_uiState.update {
it.copy(isLoading = false, resumeError = "Invalid session creation response")
}
return
}
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
// The gateway persists the row lazily on the first prompt —
@@ -1254,6 +1278,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
currentSessionId = storageId,
isSessionReady = true,
isLoading = false,
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
chatTitle = "Hermes",
@@ -1301,7 +1326,7 @@ class ChatViewModel(
// must stay the storage key — storing the runtime id here made
// every later resume 4007 "session not found" (the DB lookup
// misses) and the REST transcript 404.
val runtimeId = resultMap["session_id"] as? String ?: return
val runtimeId = (resultMap["session_id"] as? String)?.takeIf { it.isNotBlank() } ?: return
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
val generation =
resetSessionState(
@@ -1310,6 +1335,7 @@ class ChatViewModel(
isLoading = false,
)
runtimeSessionId = runtimeId
resumedGeneration = generation
ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false
@@ -1341,16 +1367,22 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?>
runtimeSessionId = resultMap?.get("session_id") as? String
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
return
}
runtimeSessionId = runtimeId
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
val sessionId =
request?.sessionId
?: (resultMap?.get("resumed") as? String)
?: (resultMap["resumed"] as? String)
?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap?.get("info") as? Map<String, Any?>
val infoMap = resultMap["info"] as? Map<String, Any?>
val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
@@ -1416,7 +1448,7 @@ class ChatViewModel(
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
val pendingApproval = resultMap["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval,
@@ -1575,6 +1607,7 @@ class ChatViewModel(
it.copy(
isLoading = false,
errorMessage = "Error ($method): $errorMsg",
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
)
}
}
@@ -1596,8 +1629,9 @@ class ChatViewModel(
* 6. For each file → await `file.attach` (requires session_id), collect @file: refs
* 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
*/
fun sendMessage(text: String) {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
fun sendMessage(text: String): Boolean {
if (!canSubmitMessage()) return false
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -1605,10 +1639,10 @@ class ChatViewModel(
// of requiring the user to hand-type the provider/model.
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
openModelPicker()
return
return true
}
handleSlashCommand(trimmed)
return
return true
}
val oversizedAttachment =
@@ -1622,7 +1656,7 @@ class ChatViewModel(
composerTextToRestore = text,
)
}
return
return true
}
// Snapshot + clear attachments so the input bar empties immediately
@@ -1654,7 +1688,7 @@ class ChatViewModel(
// Issue #969: Session creation is still in-flight. Hold the prompt
// so it is dispatched automatically the moment SESSION_CREATE lands.
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
return
return true
}
dispatchPrompt(
@@ -1665,8 +1699,23 @@ class ChatViewModel(
agentSessionId = agentSessionId,
userMessage = userMessage,
)
return true
}
private fun canSubmitMessage(): Boolean =
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
(
(_uiState.value.isSessionReady && runtimeSessionId != null) ||
(
_uiState.value.currentSessionId == null &&
pendingInitialPrompt == null &&
idToMethod.any { (id, method) ->
method == WsMethods.SESSION_CREATE &&
sessionRequestById[id]?.let { !isStaleSessionRequest(it) } == true
}
)
)
private fun dispatchPrompt(
text: String,
attachments: List<Attachment>,
@@ -1832,6 +1881,15 @@ class ChatViewModel(
},
)
} else {
// Arm the durable turn boundary BEFORE prompt.submit leaves
// the device: the REST high-watermark has to be read while
// the reply row cannot exist yet. A failed capture is
// non-fatal — the prompt is still sent, the turn just
// becomes uncorrelatable and its reply notification is never
// auto-dismissed from hydration instead of being bound to a
// guessed row.
prepareTurnCorrelation(storageSessionId)
if (dispatchGeneration != sessionGeneration) return@launch
wsClient.sendMessage(
agentSessionId,
fullText,
@@ -1851,6 +1909,17 @@ class ChatViewModel(
}
}
/**
* Arms the reply-notification turn boundary for a prompt about to be
* submitted. Best-effort by design: sending chat never depends on REST
* health, so a failed read only makes this turn's reply notification
* uncorrelatable — it stays active instead of being dismissed by a guess.
*/
private suspend fun prepareTurnCorrelation(storageSessionId: String?) {
if (storageSessionId.isNullOrBlank()) return
captureTurnBoundary(scopeId = correlationScopeId(), sessionId = storageSessionId)
}
private fun captureTurnUsageBaselineIfNeeded() {
if (_streamingState.value.turnUsageBaselineCaptured) return
_streamingState.update {
@@ -2307,8 +2376,15 @@ class ChatViewModel(
) {
if (text.isBlank()) return
val sessionId = runtimeSessionId ?: return
val storageSessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(ioDispatcher) {
// A queued prompt runs behind a turn that is already in flight, so
// no clean lower bound exists for it (see /queue). It stays
// uncorrelated rather than borrowing the running turn's boundary.
if (!queued) {
prepareTurnCorrelation(storageSessionId)
}
wsClient.sendMessage(
sessionId,
text,
@@ -2559,6 +2635,7 @@ class ChatViewModel(
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(),
showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(),
showModelProvider = AuthManager.isModelProviderShown(),
)
}
}
@@ -2730,6 +2807,7 @@ class ChatViewModel(
serverOffset,
latestPaging,
_uiState.value.messages,
context = getApplication(),
)
loadedMessageOffset = serverOffset
withContext(ioDispatcher) {
@@ -2860,6 +2938,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
messages = emptyList(),
isSessionReady = false,
currentSessionId = sessionId,
chatTitle = title,
isAgentTyping = false,
@@ -2906,6 +2985,9 @@ class ChatViewModel(
sessionId: String,
generation: Long,
) {
resumedGeneration = -1L
hydratedGeneration = -1L
_uiState.update { it.copy(isSessionReady = false) }
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value
@@ -2956,6 +3038,7 @@ class ChatViewModel(
isResumeRetrying = false,
resumeError = null,
errorMessage = null,
isSessionReady = runtimeSessionId != null,
)
}
}
@@ -3030,7 +3113,7 @@ class ChatViewModel(
) {
// Only handle if still on this session.
if (!isCurrentSessionRequest(sessionId, generation)) return
_uiState.update { it.copy(errorMessage = null) }
_uiState.update { it.copy(errorMessage = null, isSessionReady = false) }
// New session → reset the counter for a fresh backoff cycle.
if (resumeRetrySessionId != sessionId) {
@@ -3101,7 +3184,11 @@ class ChatViewModel(
* resumeSession: reconnect / reselect / Retry all reset the counter).
*/
fun retryResumeSession() {
val sessionId = _uiState.value.currentSessionId ?: return
val sessionId = _uiState.value.currentSessionId
if (sessionId == null) {
createNewSession()
return
}
val generation = sessionGeneration
cancelResumeRetry()
_uiState.update {
@@ -3154,6 +3241,7 @@ class ChatViewModel(
returnedOffset,
latestPaging,
_uiState.value.messages,
isPagingOlder = true,
)
loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
@@ -3238,6 +3326,7 @@ class ChatViewModel(
nextOffset,
latestPaging,
_uiState.value.messages,
isPagingOlder = true,
)
if (incoming.isEmpty()) return@launch
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
@@ -429,9 +429,38 @@ object ChatWsEventReducer {
// above before this return because the final snapshot is still needed
// as the next turn's cumulative baseline.
if (text.isBlank()) {
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
var updatedTargetMsg: ChatMessage? = null
val updatedMessages =
if (!event.completionId.isNullOrBlank()) {
val lastAssistantIdx =
usageState.messages
.indexOfLast {
it.role == MessageRole.ASSISTANT &&
(streamingState.sealedOrphanIds.contains(it.id) || it.id == streaming?.id)
}.takeIf { it >= 0 }
if (lastAssistantIdx != null) {
usageState.messages.toMutableList().also { list ->
val updated = list[lastAssistantIdx].copy(completionId = event.completionId)
list[lastAssistantIdx] = updated
updatedTargetMsg = updated
}
} else {
usageState.messages
}
} else {
usageState.messages
}
if (sid != null && updatedTargetMsg != null) {
effects.add(ReducerEffect.PersistMessage(updatedTargetMsg, sid))
}
effects.add(ReducerEffect.RefreshSessions)
effects.add(ReducerEffect.RefreshContextUsage)
return ReducerResult(
state = usageState.copy(isAgentTyping = false),
state = usageState.copy(messages = updatedMessages, isAgentTyping = false),
streamingState = StreamingState(),
effects = effects,
)
}
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
@@ -449,6 +478,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
completionId = event.completionId,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
@@ -456,6 +486,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
completionId = event.completionId,
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -639,12 +670,7 @@ object ChatWsEventReducer {
OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: ""
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.data?.get("tool_id") as? String, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
val updated =
@@ -675,10 +701,10 @@ object ChatWsEventReducer {
/**
* Attaches [ToolOutputRiskData] to the matching tool message.
*
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
* If no RUNNING message exists (tool already completed), falls back to the last
* COMPLETED tool with that name. This covers the case where the risk event arrives
* after tool.complete.
* Finds the matching stable tool ID first. If the event has no ID or no ID
* match exists, it falls back to the tool name for legacy payloads. It
* prefers RUNNING tools and then falls back to COMPLETED tools because the
* risk event may arrive after tool.complete.
*/
private fun onToolOutputRisk(
state: ChatUiState,
@@ -694,20 +720,11 @@ object ChatWsEventReducer {
val messages = state.messages.toMutableList()
// Prefer RUNNING tool, fall back to COMPLETED
var toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
// Prefer RUNNING tool, fall back to COMPLETED. Stable tool IDs win over
// name matching so overlapping calls to the same tool stay isolated.
var toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) {
toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.COMPLETED
}
toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.COMPLETED)
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
@@ -904,12 +921,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolProgress,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
@@ -925,12 +937,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolGenerating,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
@@ -940,6 +947,30 @@ object ChatWsEventReducer {
)
}
/** Match stable tool IDs first, with name matching for legacy events. */
private fun findToolIndex(
messages: List<ChatMessage>,
name: String?,
toolId: String?,
status: ToolStatus,
): Int {
if (!toolId.isNullOrBlank()) {
val byId =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolCallId == toolId &&
it.toolStatus == status
}
if (byId >= 0) return byId
}
return messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == name &&
it.toolStatus == status
}
}
private fun onSubagentEvent(
state: ChatUiState,
streamingState: StreamingState,
@@ -0,0 +1,241 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.GatewayFileClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.util.UUID
enum class MediaSaveStrategy {
MEDIA_STORE,
CREATE_DOCUMENT,
}
fun mediaSaveStrategy(sdkInt: Int = Build.VERSION.SDK_INT): MediaSaveStrategy =
if (sdkInt >= Build.VERSION_CODES.Q) {
MediaSaveStrategy.MEDIA_STORE
} else {
MediaSaveStrategy.CREATE_DOCUMENT
}
object MediaExportHelper {
private const val SHARE_CACHE_TTL_MS = 24 * 60 * 60 * 1000L
fun resolveDisplayName(
displayName: String?,
mimeType: String?,
uri: String,
): String {
val fallbackCandidate = mediaNameFromPath(uri).takeIf { it.isNotBlank() && it != "file" } ?: "hermes-media"
val candidate = displayName?.takeIf { it.isNotBlank() } ?: fallbackCandidate
val leaf = candidate.split('/', '\\').lastOrNull { it.isNotBlank() } ?: "hermes-media"
val rawExt = extractFileExtension(leaf)
val hasValidExt = rawExt.isNotBlank() && rawExt.length <= 10
val finalExt =
if (hasValidExt) {
rawExt
} else {
mimeType?.let { extensionForMime(it) } ?: "bin"
}
val rawBase = if (hasValidExt) leaf.substringBeforeLast('.') else leaf
val cleanedBase =
rawBase
.replace(Regex("[^A-Za-z0-9_\\-]"), "_")
.trim('_')
.take(60)
.ifBlank { "hermes-media" }
val cleanedExt =
finalExt
.replace(Regex("[^A-Za-z0-9]"), "")
.take(10)
.ifBlank { "bin" }
return "$cleanedBase.$cleanedExt"
}
/**
* Best-effort cleanup of stale share directories older than 24 hours.
* Only touches subdirectories inside [sharedMediaDir].
*/
internal fun sweepOldShareFiles(
sharedMediaDir: File,
now: Long = System.currentTimeMillis(),
) {
val cutoff = now - SHARE_CACHE_TTL_MS
runCatching {
sharedMediaDir.listFiles()?.forEach { entry ->
if (entry.isDirectory) {
if (entry.lastModified() < cutoff) {
entry.deleteRecursively()
}
} else if (entry.isFile && entry.lastModified() < cutoff) {
entry.delete()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
internal fun createMediaStoreValues(
displayName: String,
mimeType: String,
length: Long?,
): ContentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (length != null && length > 0) {
put(MediaStore.MediaColumns.SIZE, length)
}
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
/**
* Streams media directly to [MediaStore.Downloads] on API 29+ (Android Q+).
* Uses bounded 64 KiB chunks with periodic coroutine cancellation checks.
* Marked pending during write and un-pended on commit.
* Guarantees that any partial item is deleted if the write fails or is canceled.
*/
@RequiresApi(Build.VERSION_CODES.Q)
suspend fun saveMediaToDownloads(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
currentSdk: Int = Build.VERSION.SDK_INT,
valuesFactory: (String, String, Long?) -> ContentValues = ::createMediaStoreValues,
): String =
withContext(Dispatchers.IO) {
if (currentSdk < Build.VERSION_CODES.Q) {
throw UnsupportedOperationException("Direct MediaStore.Downloads requires API 29+")
}
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val frameworkMime = normalizeFrameworkMime(streamInfo.mimeType)
val values = valuesFactory(finalName, frameworkMime, streamInfo.length)
val resolver = context.contentResolver
val targetUri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: throw IOException("Failed to insert into MediaStore.Downloads")
var success = false
try {
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open MediaStore output stream")
val publishValues = valuesFactory(finalName, frameworkMime, streamInfo.length)
publishValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
val updated = resolver.update(targetUri, publishValues, null, null)
if (updated <= 0) {
throw IOException("Failed to publish media item in MediaStore")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media directly into a user-selected [targetUri] obtained via SAF (e.g. CreateDocument).
* Used for API 26-28 where MediaStore.Downloads is unavailable.
* Deletes partial documents if the stream write fails or is canceled.
*/
suspend fun saveMediaToUri(
context: Context,
sourceUri: String,
targetUri: Uri,
fallbackMime: String = "application/octet-stream",
): String =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
var success = false
try {
MediaStreamResolver.useStream(context, sourceUri, fallbackMime) { inputStream, _ ->
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open target document output stream")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { DocumentsContract.deleteDocument(resolver, targetUri) }
.onFailure {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media into an internal cache file in `shared_media/<uuid>/<safeName>` and builds
* an ACTION_SEND Intent. Uses collision-proof session subdirectories and cleans stale entries
* older than 24 hours. Share files are preserved so receiving apps can read them.
*/
suspend fun shareMedia(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
fileUriProvider: (Context, File) -> Uri = { ctx, f ->
FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", f)
},
intentFactory: (Uri, String) -> Intent = { contentUri, mime ->
Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
},
): Intent? =
withContext(Dispatchers.IO) {
try {
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val sharedMediaDir = File(context.cacheDir, "shared_media").also { it.mkdirs() }
sweepOldShareFiles(sharedMediaDir)
val sessionDir = File(sharedMediaDir, UUID.randomUUID().toString()).also { it.mkdirs() }
val targetFile = File(sessionDir, finalName)
var success = false
try {
targetFile.outputStream().use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
}
success = true
} finally {
if (!success) {
targetFile.delete()
sessionDir.delete()
}
}
val contentUri = fileUriProvider(context, targetFile)
val normalizedMime = normalizeFrameworkMime(streamInfo.mimeType)
intentFactory(contentUri, normalizedMime)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,240 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.net.Uri
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.Base64
object MediaStreamResolver {
data class StreamInfo(
val mimeType: String,
val extension: String,
val length: Long?,
)
/**
* Streams characters directly from a [CharSequence] without allocating substring copies.
* Skips whitespace to tolerate formatted Base64 data URLs.
*/
internal class CharSequenceInputStream(
private val value: CharSequence,
startIndex: Int,
) : InputStream() {
private var index = startIndex
override fun read(): Int {
while (index < value.length) {
val c = value[index++]
if (!c.isWhitespace()) {
return c.code and 0xFF
}
}
return -1
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var bytesRead = 0
while (bytesRead < len) {
val c = read()
if (c == -1) {
return if (bytesRead == 0) -1 else bytesRead
}
b[off + bytesRead] = c.toByte()
bytesRead++
}
return bytesRead
}
}
/**
* Resolves the media source identified by [uriString] and streams its content
* into [consumer]. Guarantees that all underlying streams and HTTP response bodies
* are strictly closed via [use], and that no whole-media buffering occurs.
*/
suspend fun <T> useStream(
context: Context,
uriString: String,
fallbackMime: String = "application/octet-stream",
consumer: suspend (InputStream, StreamInfo) -> T,
): T =
withContext(Dispatchers.IO) {
try {
when {
uriString.startsWith("data:", ignoreCase = true) -> {
openDataUrlStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("content://", ignoreCase = true) -> {
openContentStream(context, uriString, fallbackMime, consumer)
}
uriString.startsWith("file:", ignoreCase = true) -> {
openFileUriStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("/") -> {
openLocalFileStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("https://", ignoreCase = true) ||
uriString.startsWith("http://", ignoreCase = true) -> {
openHttpStream(uriString, fallbackMime, consumer)
}
else -> {
throw IOException("Unsupported media source: $uriString")
}
}
} catch (e: CancellationException) {
throw e
}
}
private suspend fun <T> openDataUrlStream(
dataUrl: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val comma = dataUrl.indexOf(',')
if (comma < 0) throw IOException("Malformed data URL")
val meta = dataUrl.substring(0, comma)
val mime =
meta
.substringAfter("data:", fallbackMime)
.substringBefore(";")
.ifBlank { fallbackMime }
if (!meta.contains(";base64", ignoreCase = true)) {
throw IOException("Only base64 data URLs are supported")
}
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
val decodingStream = Base64.getDecoder().wrap(rawStream)
return decodingStream.use { stream ->
consumer(
stream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = null,
),
)
}
}
private suspend fun <T> openContentStream(
context: Context,
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val uri = Uri.parse(uriString)
val resolvedMime = runCatching { context.contentResolver.getType(uri) }.getOrNull()
val mime = resolvedMime?.takeIf { it.isNotBlank() } ?: fallbackMime
val length =
runCatching {
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
pfd.statSize.takeIf { it >= 0 }
}
}.getOrNull()
val stream =
runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
?: throw IOException("Could not open content URI: $uriString")
return stream.use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
private suspend fun <T> openFileUriStream(
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openLocalFileStream(
filePath: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val file = File(filePath)
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: $filePath")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openHttpStream(
url: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val request = Request.Builder().url(url).build()
val response = MediaDataSourceProvider.mediaOkHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed (HTTP ${response.code})")
}
val body = response.body
return response.use { resp ->
val headerMime = resp.header("Content-Type")?.substringBefore(";")?.trim()
val mime =
when {
!headerMime.isNullOrBlank() && headerMime != "application/octet-stream" -> headerMime
fallbackMime.isNotBlank() && fallbackMime != "application/octet-stream" -> fallbackMime
else -> mediaMimeForPath(url)
}
val length = body.contentLength().takeIf { it >= 0 }
body.byteStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
}
}
@@ -4,12 +4,10 @@ import java.net.URLDecoder
import java.nio.charset.StandardCharsets
/**
* File-kind classification for gateway-hosted files, mirroring the desktop app's
* File-kind classification for media and gateway-hosted files, mirroring the desktop app's
* `MEDIA_BY_EXT` table (`apps/desktop/src/lib/media.ts`). Lets UI code decide
* how to present a downloaded file — inline image, audio player, video player,
* how to present a file — inline image, audio player, video player,
* or a generic file card — without re-deriving the mapping at every call site.
*
* Extend the table (not the call sites) when new extensions are needed.
*/
enum class MediaKind {
IMAGE,
@@ -20,6 +18,8 @@ enum class MediaKind {
private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
mapOf(
"3gp" to (MediaKind.VIDEO to "video/3gpp"),
"aac" to (MediaKind.AUDIO to "audio/aac"),
"avi" to (MediaKind.VIDEO to "video/x-msvideo"),
"bmp" to (MediaKind.IMAGE to "image/bmp"),
"csv" to (MediaKind.FILE to "text/csv"),
@@ -42,50 +42,189 @@ private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
"webp" to (MediaKind.IMAGE to "image/webp"),
)
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
/**
* Extracts the file extension from a clean path or URL, ignoring query parameters and fragments.
* Never searches queries for extensions to avoid false positives.
*/
fun extractFileExtension(pathOrUrl: String): String {
val clean =
pathOrUrl
.substringBefore('#')
.substringBefore('?')
.trim()
val lastSegment = clean.substringAfterLast('/').substringAfterLast('\\')
val ext = lastSegment.substringAfterLast('.', "")
return if (ext == lastSegment) "" else ext.lowercase()
}
/**
* Extracts the file extension for a media source, handling gateway URLs where the path
* is encoded inside the `path=` query parameter.
*/
fun extractExtensionFromMediaSource(source: String): String {
val sourcePath = source.substringBefore('?').substringBefore('#')
val gatewayPath =
if (sourcePath.endsWith("/api/files/download") || sourcePath.endsWith("/api/files/stream")) {
gatewayPathFromUrl(source)
} else {
null
}
gatewayPath?.let { gwPath ->
val ext = extractFileExtension(gwPath)
if (ext.isNotBlank()) return ext
}
return extractFileExtension(source)
}
/**
* Robust classification of a media item by concrete MIME type first, then by file extension
* from [name] or [uri]. Concrete MIME types take precedence (e.g. "audio/mp4" is recognized as AUDIO).
*/
fun classifyMedia(
mimeType: String? = null,
name: String? = null,
uri: String? = null,
): MediaKind {
val cleanMime =
mimeType
?.substringBefore(';')
?.trim()
?.lowercase()
.orEmpty()
return MEDIA_BY_EXT[ext]?.first ?: MediaKind.FILE
if (cleanMime.isNotBlank() && cleanMime != "application/octet-stream" && cleanMime != "*/*") {
if (cleanMime.startsWith("audio/") || cleanMime == "application/ogg" || cleanMime == "application/x-flac") {
return MediaKind.AUDIO
}
if (cleanMime.startsWith("video/")) {
return MediaKind.VIDEO
}
if (cleanMime.startsWith("image/")) {
return MediaKind.IMAGE
}
return MediaKind.FILE
}
if (uri?.startsWith("data:", ignoreCase = true) == true) {
return classifyMedia(mimeType = uri.substringAfter(':').substringBefore(';').substringBefore(','))
}
val extFromName = name?.let { extractFileExtension(it) }.orEmpty()
if (extFromName.isNotBlank()) {
MEDIA_BY_EXT[extFromName]?.first?.let { return it }
}
val extFromUri = uri?.let { extractExtensionFromMediaSource(it) }.orEmpty()
if (extFromUri.isNotBlank()) {
MEDIA_BY_EXT[extFromUri]?.first?.let { return it }
}
return MediaKind.FILE
}
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind = classifyMedia(uri = path)
/** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */
fun mediaMimeForPath(path: String): String {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
?.lowercase()
.orEmpty()
val ext = extractExtensionFromMediaSource(path)
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream"
}
/** Decoded gateway file path carried in a `/api/files/download` URL. */
/**
* Normalizes a MIME type string for Android framework boundaries (MediaStore, Intent, SAF),
* stripping parameters such as codecs (e.g. `audio/ogg; codecs=opus` -> `audio/ogg`).
*/
fun normalizeFrameworkMime(mime: String?): String =
mime
?.substringBefore(';')
?.trim()
?.takeIf { it.isNotBlank() }
?: "application/octet-stream"
/** Returns the canonical file extension for a given MIME type. */
fun extensionForMime(mime: String): String {
val clean = mime.substringBefore(';').trim().lowercase()
for ((ext, pair) in MEDIA_BY_EXT) {
if (pair.second
.substringBefore(';')
.trim()
.lowercase() == clean
) {
return ext
}
}
return when (clean) {
"image/jpeg", "image/jpg" -> {
"jpg"
}
"video/mp4" -> {
"mp4"
}
"video/x-matroska" -> {
"mkv"
}
"video/quicktime" -> {
"mov"
}
"audio/mpeg", "audio/mp3" -> {
"mp3"
}
"audio/mp4", "audio/x-m4a" -> {
"m4a"
}
"audio/wav", "audio/x-wav", "audio/wave" -> {
"wav"
}
"audio/flac", "application/x-flac" -> {
"flac"
}
"audio/ogg", "application/ogg" -> {
"ogg"
}
"audio/aac" -> {
"aac"
}
else -> {
when {
clean.startsWith("video/") -> "mp4"
clean.startsWith("audio/") -> "mp3"
clean.startsWith("image/") -> "img"
else -> "bin"
}
}
}
}
/** Decoded gateway file path carried in a `/api/files/download` or `/api/files/stream` URL. */
fun gatewayPathFromUrl(url: String): String? =
Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let {
runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it)
}
/** Trailing filename from a path or URL.
*
/**
* Trailing filename from a path or URL.
* Handles both a bare gateway path (`/tmp/report.pdf`) and a full
* `/api/files/download?path=<enc>` URL, where the real filename lives in the
* percent-encoded `path=` query parameter. */
* percent-encoded `path=` query parameter.
*/
fun mediaNameFromPath(path: String): String {
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
val cleanPath = path.split('?', limit = 2)[0]
val cleanPath = path.substringBefore('#').substringBefore('?')
return runCatching {
java.net
.URI(cleanPath)
.path
.split('/')
.lastOrNull { it.isNotBlank() }
?.split('/')
?.lastOrNull { it.isNotBlank() }
}.getOrNull()
?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() }
?: path
@@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +28,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
@@ -43,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -57,6 +60,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
@@ -65,13 +69,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Composes the collapsed summary lines for a tool card.
@@ -237,34 +249,7 @@ internal fun ToolBubble(
Spacer(modifier = Modifier.height(6.dp))
if (showRawJson) {
// Raw JSON view — selectable + copy button
Box {
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
ToolRawJsonView(rawContent = message.content)
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -310,34 +295,8 @@ internal fun ToolBubble(
.clickable(role = Role.Button) { showRawJson = true },
)
} else {
// Unparseable content — show raw JSON, selectable
Box {
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
// Unparseable content — show raw JSON
ToolRawJsonView(rawContent = message.content)
}
}
@@ -650,6 +609,123 @@ internal fun SecurityRiskChip(
}
}
@Composable
private fun ToolRawJsonView(
rawContent: String,
modifier: Modifier = Modifier,
) {
var formatJson by remember { mutableStateOf(true) }
val formattedContent = remember(rawContent) { ToolJson.prettyPrintJson(rawContent) }
val isFormatDifferent = formattedContent != rawContent
val displayText = if (formatJson && isFormatDifferent) formattedContent else rawContent
val highlighted by produceState(
initialValue = remember(displayText) { AnnotatedString(displayText) },
key1 = displayText,
) {
value =
withContext(Dispatchers.Default) {
highlightSyntax(displayText)
}
}
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("tool_raw_json"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "JSON",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeTerminalMuted,
),
)
Spacer(Modifier.weight(1f))
if (isFormatDifferent) {
Text(
text =
if (formatJson) {
stringResource(R.string.chat_tool_compact_json)
} else {
stringResource(R.string.chat_tool_format_json)
},
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("tool_json_format_toggle")
.clickable(role = Role.Button) { formatJson = !formatJson }
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText)))
}
copied = true
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription =
if (copied) {
stringResource(R.string.content_desc_copied)
} else {
stringResource(R.string.content_desc_copy)
},
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
SelectionContainer {
Text(
text = highlighted,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = CodeTerminalText,
softWrap = true,
)
}
}
}
}
@Composable
private fun CopyButton(
visible: Boolean,
@@ -80,6 +80,8 @@ fun ChatInputBar(
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
sessionPreparationFailed: Boolean = false,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {},
@@ -98,12 +100,13 @@ fun ChatInputBar(
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
@@ -203,6 +206,22 @@ fun ChatInputBar(
}
}
if (isConnected && !isSessionReady) {
Text(
text =
stringResource(
if (sessionPreparationFailed) {
R.string.chat_session_not_ready
} else {
R.string.chat_session_preparing
},
),
style = MaterialTheme.typography.labelSmall,
color = palette.placeholder,
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
)
}
// ── TOP ROW: Borderless input field ──
Row(
modifier =
@@ -296,6 +315,7 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
onSend = onSend,
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
@@ -308,6 +328,7 @@ fun ChatInputBar(
fastSupported = fastSupported,
isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode,
showModelProvider = showModelProvider,
)
AttachmentTray(
@@ -10,18 +10,24 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ChatLifecycleEffects(
@@ -39,6 +45,7 @@ fun ChatLifecycleEffects(
scrollController: ChatScrollController,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
isOverlayActive: Boolean = false,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -87,6 +94,32 @@ fun ChatLifecycleEffects(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val messageMap = remember(messages) { messages.associateBy { it.id } }
// Auto-dismiss reply notifications when their message is displayed in the viewport
LaunchedEffect(lifecycleOwner, currentSessionId, messageMap, listState, isOverlayActive) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
snapshotFlow<List<ChatMessage>> {
if (currentSessionId.isNullOrBlank() || isOverlayActive) {
emptyList()
} else {
ChatReadObserver.findVisibleAssistantMessages(listState.layoutInfo, messageMap)
}
}.distinctUntilChanged()
.collect { visibleAssistantMsgs ->
val scopeId = correlationScopeId()
for (msg in visibleAssistantMsgs) {
ReplyNotificationTracker.onMessageVisible(
context = context,
scopeId = scopeId,
sessionId = currentSessionId,
completionId = msg.completionId,
)
}
}
}
}
// Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission =
rememberLauncherForActivityResult(
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
object ChatReadObserver {
/**
* Inspects the LazyColumn's visible items to determine which completed assistant
* messages currently have any portion visible within the viewport.
*
* In [com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList], assistant prose
* bubbles are keyed with `"prose-${message.id}"`. An item is visible when its top
* edge is before the viewport end offset AND its bottom edge (offset + size) is past
* the viewport start offset.
*/
fun findVisibleAssistantMessages(
layoutInfo: LazyListLayoutInfo,
messageMap: Map<String, ChatMessage>,
): List<ChatMessage> {
val visible = layoutInfo.visibleItemsInfo
if (visible.isEmpty() || messageMap.isEmpty()) return emptyList()
val start = layoutInfo.viewportStartOffset
val end = layoutInfo.viewportEndOffset
return visible
.filter { it.offset + it.size > start && it.offset < end }
.mapNotNull { item ->
val key = item.key as? String ?: return@mapNotNull null
if (key.startsWith("prose-")) {
val msgId = key.removePrefix("prose-")
val msg = messageMap[msgId]
if (msg != null && msg.role == MessageRole.ASSISTANT && !msg.isStreaming) {
msg
} else {
null
}
} else {
null
}
}
}
fun findVisibleAssistantMessages(
layoutInfo: LazyListLayoutInfo,
messages: List<ChatMessage>,
): List<ChatMessage> = findVisibleAssistantMessages(layoutInfo, messages.associateBy { it.id })
}
@@ -95,6 +95,7 @@ fun ComposerToolbar(
onMicTap: () -> Unit,
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
@@ -102,6 +103,7 @@ fun ComposerToolbar(
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
@@ -134,7 +136,11 @@ fun ComposerToolbar(
// Model + reasoning pill — wraps its content inside the free space,
// pushing the mic/action buttons to the far end
val modelScrollState = rememberScrollState()
LaunchedEffect(currentSessionModel) {
val modelLabel =
currentSessionModel?.let { model ->
composerModelLabel(model, showProvider = showModelProvider)
} ?: "Model"
LaunchedEffect(modelLabel) {
modelScrollState.scrollTo(0)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
@@ -158,9 +164,7 @@ fun ComposerToolbar(
.testTag("model_chip"),
) {
Text(
// Keeps the provider so same-named models from different providers
// stay distinguishable while allowing the full label to be revealed.
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
text = modelLabel,
style = MaterialTheme.typography.bodyMedium,
color = palette.onControl,
maxLines = 1,
@@ -360,7 +364,7 @@ fun ComposerToolbar(
// Flat mic / stop button — only while the action button is in send mode
AnimatedVisibility(
visible = canSend,
visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
@@ -382,10 +386,10 @@ fun ComposerToolbar(
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (canSend) onSend else onMicTap,
enabled = canSend || isConnected,
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!canSend && isListening) {
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
@@ -398,7 +402,7 @@ fun ComposerToolbar(
.size(ControlSize)
.testTag(
when {
canSend -> "send_button"
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
@@ -407,7 +411,7 @@ fun ComposerToolbar(
Crossfade(
targetState =
when {
canSend -> ActionGlyph.SEND
showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
},
@@ -453,19 +457,39 @@ private fun listeningIconButtonColors(): IconButtonColors =
)
/**
* Model label for the composer pill: the session's "provider/model" id with
* Model label for the composer pill:
* When [showProvider] is true, shows the session's "provider/model" id with
* only the "custom:" marker removed from the provider ("custom:acme/glm-5.3"
* → "acme/glm-5.3"). The provider itself is kept so the same model served by
* → "acme/glm-5.3"). The provider is kept so the same model served by
* different providers never renders identically.
* When [showProvider] is false (default), shows only the model name ("openai/gpt-5"
* → "gpt-5", "custom:acme/glm-5.3" → "glm-5.3").
*/
internal fun composerModelLabel(sessionModel: String): String {
internal fun composerModelLabel(
sessionModel: String,
showProvider: Boolean = false,
): String {
val slash = sessionModel.indexOf('/')
if (slash <= 0) return sessionModel
val provider = sessionModel.substring(0, slash)
if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) {
return sessionModel
val rawProvider = sessionModel.substring(0, slash)
val model = sessionModel.substring(slash + 1)
if (model.isBlank()) return sessionModel
if (rawProvider == CUSTOM_PROVIDER_PREFIX) return sessionModel
if (!showProvider) {
val leaf = sessionModel.substringAfterLast('/')
return if (leaf.isNotBlank()) leaf else sessionModel
}
return provider.removePrefix(CUSTOM_PROVIDER_PREFIX) + sessionModel.substring(slash)
val provider =
if (rawProvider.startsWith(CUSTOM_PROVIDER_PREFIX) && rawProvider.length > CUSTOM_PROVIDER_PREFIX.length) {
rawProvider.removePrefix(CUSTOM_PROVIDER_PREFIX)
} else {
rawProvider
}
return "$provider/$model"
}
private const val CUSTOM_PROVIDER_PREFIX = "custom:"
@@ -0,0 +1,211 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
/**
* Shared inline media preview card for audio and video in chat bubbles and markdown blocks.
* Renders a compact file-style card for audio and a 16:9 preview for video.
*/
@Composable
fun InlineMediaPlayer(
uri: String,
modifier: Modifier = Modifier,
title: String? = null,
mimeType: String? = null,
onFullScreenClick: () -> Unit = {},
) {
val mediaKind =
remember(uri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = uri)
}
val displayName =
remember(uri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(uri)
}
if (mediaKind == MediaKind.AUDIO) {
InlineAudioPreview(
name = displayName,
modifier = modifier,
onClick = onFullScreenClick,
)
} else {
InlineVideoPreview(
modifier = modifier,
onClick = onFullScreenClick,
)
}
}
@Composable
private fun InlineAudioPreview(
name: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
modifier
.fillMaxWidth()
.clickable { onClick() }
.testTag("inline_audio_player"),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier =
Modifier
.size(42.dp)
.testTag("audio_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(R.string.media_player_audio).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_audio),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@Composable
private fun InlineVideoPreview(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onClick() }
.testTag("inline_video_player"),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_video),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_video),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " " + stringResource(R.string.media_player_video).uppercase(),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@@ -1,34 +1,10 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
/**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
* Backwards-compatible wrapper delegating to [InlineMediaPlayer].
*/
@Composable
fun InlineVideoPlayer(
@@ -36,65 +12,11 @@ fun InlineVideoPlayer(
modifier: Modifier = Modifier,
onFullScreenClick: () -> Unit = {},
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onFullScreenClick() }
.testTag("inline_video_player"),
) {
// Play icon badge in center
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
// VIDEO label badge in bottom-right corner
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Video",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " VIDEO",
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
InlineMediaPlayer(
uri = videoUri,
modifier = modifier,
onFullScreenClick = onFullScreenClick,
)
}
/** Formats milliseconds into `M:SS` or `H:MM:SS` */
@@ -0,0 +1,714 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MediaExportHelper
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer.
*/
@OptIn(UnstableApi::class)
@Composable
fun MediaViewerDialog(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String? = null,
mimeType: String? = null,
) {
key(mediaUri, mimeType) {
MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
}
}
@OptIn(UnstableApi::class)
@Composable
private fun MediaViewerContent(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String?,
mimeType: String?,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val mediaKind =
remember(mediaUri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = mediaUri)
}
val isAudio = mediaKind == MediaKind.AUDIO
val displayName =
remember(mediaUri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri)
}
var isPlaying by remember { mutableStateOf(false) }
var isBuffering by remember { mutableStateOf(true) }
var isEnded by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var isDragging by remember { mutableStateOf(false) }
var dragPositionMs by remember { mutableLongStateOf(0L) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
val saveFailed = stringResource(R.string.media_player_save_failed)
val shareFailed = stringResource(R.string.media_player_share_failed)
val shareTitle = stringResource(R.string.media_player_share_title)
val player =
remember(context, mediaUri) {
val dataSourceFactory = MediaDataSourceProvider.createDataSourceFactory(context)
val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
val audioAttributes =
AudioAttributes
.Builder()
.setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_MUSIC else C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
ExoPlayer
.Builder(context)
.setRenderersFactory(
// Emulator guard: the goldfish "hardware" codecs crash on queueInputBuffer
// ("Decoder failed: c2.goldfish.h264.decoder"); deprioritize them so the
// reliable software codecs are selected instead. Real devices never ship
// goldfish codecs, so this has no effect on phones.
androidx.media3.exoplayer
.DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
androidx.media3.exoplayer.mediacodec.MediaCodecSelector.DEFAULT
.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
.sortedBy { it.name.contains("goldfish", ignoreCase = true) }
},
).setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(mediaSourceFactory)
.build()
.apply {
setMediaItem(MediaItem.fromUri(Uri.parse(mediaUri)))
}
}
DisposableEffect(player) {
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_BUFFERING -> {
isBuffering = true
}
Player.STATE_READY -> {
isBuffering = false
hasError = false
durationMs = player.duration.coerceAtLeast(0L)
}
Player.STATE_ENDED -> {
isBuffering = false
isPlaying = false
isEnded = true
currentPositionMs = durationMs
}
Player.STATE_IDLE -> {
isBuffering = false
}
}
}
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
if (isPlayingNow) {
isEnded = false
}
}
override fun onPlayerError(error: PlaybackException) {
isBuffering = false
hasError = true
}
}
player.addListener(listener)
player.prepare()
player.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
onDispose {
player.removeListener(listener)
player.release()
}
}
DisposableEffect(lifecycleOwner, player) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
player.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(player, isPlaying, isDragging) {
while (isPlaying && !isDragging) {
currentPositionMs = player.currentPosition.coerceAtLeast(0L)
if (durationMs <= 0L && player.duration > 0L) {
durationMs = player.duration
}
delay(200)
}
}
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
prepareForBackground = { NotificationHelper.start(context) },
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
launch = launch,
)
}
val saveDocumentLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
try {
ExternalActivityLifecycleGuard.externalActivityReturned()
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
if (destination != null) {
scope.launch {
try {
val msg =
MediaExportHelper.saveMediaToUri(
context = context,
sourceUri = mediaUri,
targetUri = destination,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
} else {
isBusy = false
}
} catch (e: Throwable) {
isBusy = false
if (e is CancellationException) throw e
}
}
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
when (mediaSaveStrategy()) {
MediaSaveStrategy.MEDIA_STORE -> {
scope.launch {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val msg =
MediaExportHelper.saveMediaToDownloads(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
MediaSaveStrategy.CREATE_DOCUMENT -> {
val suggestedName =
MediaExportHelper.resolveDisplayName(
displayName = displayName,
mimeType = mimeType,
uri = mediaUri,
)
val rawMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName)
val normalizedMime = normalizeFrameworkMime(rawMime)
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = normalizedMime
putExtra(Intent.EXTRA_TITLE, suggestedName)
}
try {
launchExternalActivity {
saveDocumentLauncher.launch(intent)
}
} catch (_: Exception) {
isBusy = false
scope.launch(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch {
try {
val intent =
MediaExportHelper.shareMedia(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
if (intent != null) {
val chooser = Intent.createChooser(intent, shareTitle)
try {
launchExternalActivity {
context.startActivity(chooser)
}
} catch (_: Exception) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.testTag("media_viewer_dialog"),
) {
if (!isAudio) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
// Keep the background gesture out of the controls' ancestor chain.
Box(
Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (isPlaying && !isDragging) showControls = !showControls
},
)
} else if (!isBuffering && !hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(bottom = 180.dp, start = 24.dp, end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(100.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
// Top Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("media_close_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.media_player_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
IconButton(
onClick = onSave,
enabled = !isBusy,
modifier = Modifier.testTag("media_save_button"),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.media_player_save),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(
onClick = onShare,
enabled = !isBusy,
modifier = Modifier.testTag("media_share_button"),
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.media_player_share),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
// Center play/pause/loading/error
if (isBuffering) {
CircularProgressIndicator(
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp)
.testTag("media_buffering_indicator"),
color = MaterialTheme.colorScheme.primary,
)
} else if (hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.media_player_load_failed, ""),
style = MaterialTheme.typography.bodyMedium,
color = statusColors.error,
modifier = Modifier.padding(bottom = 12.dp),
)
IconButton(
onClick = {
hasError = false
isBuffering = true
player.prepare()
player.play()
},
modifier = Modifier.size(56.dp).testTag("media_retry_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.media_player_retry),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
}
}
} else {
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
modifier =
Modifier
.size(64.dp)
.testTag("media_play_pause_button")
.clickable {
if (isEnded) {
player.seekTo(0)
player.play()
isEnded = false
} else if (isPlaying) {
player.pause()
} else {
player.play()
}
},
) {
Box(contentAlignment = Alignment.Center) {
val icon =
when {
isEnded -> Icons.Filled.Replay
isPlaying -> Icons.Filled.Pause
else -> Icons.Filled.PlayArrow
}
val desc =
when {
isEnded -> stringResource(R.string.media_player_replay)
isPlaying -> stringResource(R.string.media_player_pause)
else -> stringResource(R.string.media_player_play)
}
Icon(
imageVector = icon,
contentDescription = desc,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
}
// Bottom Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.fillMaxWidth(),
) {
val displayPosition = if (isDragging) dragPositionMs else currentPositionMs
Column(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatMediaDuration(displayPosition),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
Slider(
value = displayPosition.coerceIn(0L, durationMs.coerceAtLeast(1L)).toFloat(),
onValueChange = {
isDragging = true
dragPositionMs = it.toLong()
},
onValueChangeFinished = {
player.seekTo(dragPositionMs)
currentPositionMs = dragPositionMs
isEnded = false
isDragging = false
},
enabled = durationMs > 0 && !hasError && player.isCurrentMediaItemSeekable,
valueRange = 0f..durationMs.coerceAtLeast(1L).toFloat(),
modifier = Modifier.weight(1f).testTag("media_seek_slider"),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
}
}
}
}
}
}
@@ -48,8 +48,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -57,6 +55,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -91,7 +90,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
// ── ReasoningCard ─────────────────────────────────────────────────────────
@@ -317,7 +318,15 @@ fun CodeBlockCard(
}
}
// Code content with syntax highlighting
val highlighted = remember(code) { highlightSyntax(code) }
val highlighted by produceState(
initialValue = remember(code) { AnnotatedString(code) },
key1 = code,
) {
value =
withContext(Dispatchers.Default) {
highlightSyntax(code)
}
}
Text(
text = highlighted,
fontFamily = FontFamily.Monospace,
@@ -338,6 +347,7 @@ private val HIGHLIGHT_TOKENS =
listOf(
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
@@ -354,7 +364,7 @@ private val HIGHLIGHT_TOKENS =
),
CodeKeyword,
),
TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation),
TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
)
/**
@@ -1,382 +1,17 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import android.widget.Toast
import android.widget.VideoView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ImageBytesResolver
import com.m57.hermescontrol.ui.chat.MediaImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Fullscreen Video Viewer modal dialog with Save & Share (issue #722).
* Backwards-compatible wrapper delegating to [MediaViewerDialog].
*/
@Composable
fun VideoViewerDialog(
videoUri: String,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isPlaying by remember { mutableStateOf(true) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var videoViewRef by remember { mutableStateOf<VideoView?>(null) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val savedMsg = stringResource(R.string.image_viewer_saved)
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
val shareTitle = stringResource(R.string.image_viewer_share_title)
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val result =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val uri =
MediaImageStore.saveToDownloads(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
if (uri != null) savedMsg else saveFailedMsg
}
is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message)
}
}
withContext(Dispatchers.Main) {
isBusy = false
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val intent =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
MediaImageStore.buildShareIntent(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
}
is ImageBytesResolver.Result.Error -> {
null
}
}
withContext(Dispatchers.Main) {
isBusy = false
if (intent != null) {
context.startActivity(android.content.Intent.createChooser(intent, shareTitle))
} else {
Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show()
}
}
}
}
}
LaunchedEffect(isPlaying) {
while (isPlaying) {
videoViewRef?.let { view ->
if (view.isPlaying) {
currentPositionMs = view.currentPosition.toLong()
if (durationMs <= 0 && view.duration > 0) {
durationMs = view.duration.toLong()
}
}
}
delay(250)
}
}
DisposableEffect(videoUri) {
onDispose {
videoViewRef?.stopPlayback()
}
}
Dialog(
MediaViewerDialog(
mediaUri = videoUri,
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim)
.clickable { showControls = !showControls }
.testTag("video_viewer_dialog"),
) {
AndroidView(
factory = { ctx ->
VideoView(ctx).apply {
val uri = Uri.parse(videoUri)
val headers = mutableMapOf<String, String>()
val cookieVal =
com.m57.hermescontrol.data.remote.CookieManager
.getSessionCookie()
if (!cookieVal.isNullOrBlank()) {
headers["Cookie"] = "hermes_session_at=$cookieVal"
}
val token =
com.m57.hermescontrol.data.local.AuthManager
.getToken()
if (!token.isNullOrBlank()) {
headers["Authorization"] = "Bearer $token"
}
setVideoURI(uri, headers)
setOnPreparedListener { mp ->
durationMs = mp.duration.toLong()
start()
isPlaying = true
}
setOnCompletionListener {
isPlaying = false
currentPositionMs = durationMs
}
}
},
update = { view ->
videoViewRef = view
},
modifier = Modifier.fillMaxSize(),
)
// Center overlay play button when paused
if (!isPlaying) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(64.dp)
.clickable {
videoViewRef?.let { view ->
view.start()
isPlaying = true
}
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
// Top Bar with Back/Close, Save, Share
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopCenter),
) {
Surface(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.70f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("close_video_dialog_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.weight(1f))
if (isBusy) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
} else {
IconButton(onClick = onSave, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
IconButton(onClick = onShare, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
// Floating Bottom Controls Bar
AnimatedVisibility(
visible = showControls || !isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.80f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
videoViewRef?.let { view ->
if (isPlaying) {
view.pause()
isPlaying = false
} else {
view.start()
isPlaying = true
}
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatMediaDuration(currentPositionMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
Slider(
value = if (durationMs > 0) currentPositionMs.toFloat() / durationMs.toFloat() else 0f,
onValueChange = { fraction ->
val targetMs = (fraction * durationMs).toLong()
currentPositionMs = targetMs
videoViewRef?.seekTo(targetMs.toInt())
},
modifier =
Modifier
.weight(1f)
.padding(horizontal = 8.dp),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.inverseOnSurface,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
}
}
}
}
}
)
}
@@ -314,4 +314,27 @@ internal object ToolJson {
return formatDurationSeconds(seconds)
}
private val PRETTY_JSON =
Json {
prettyPrint = true
prettyPrintIndent = " "
}
/**
* Pretty-prints a raw JSON string with 2-space indentation.
* Returns the original string if [raw] is not valid JSON.
*/
fun prettyPrintJson(raw: String): String {
val trimmed = raw.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return raw
}
return try {
val element = Json.parseToJsonElement(trimmed)
PRETTY_JSON.encodeToString(JsonElement.serializer(), element)
} catch (_: Exception) {
raw
}
}
}
@@ -25,6 +25,9 @@ internal interface ToolRenderer {
/** Expanded body text. */
fun detail(call: ToolCall): String? = null
/** Expanded body text while the call is still running. */
fun pendingDetail(call: ToolCall): String? = null
/** Structured extras (streams, diffs, search hits) for special row layouts. */
fun extras(
call: ToolCall,
@@ -64,7 +64,13 @@ object ToolViewBuilder {
val detailBody =
ToolJson.stripDividerLines(
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult),
if (running) {
renderer.pendingDetail(call)
?: renderer.detail(call)
?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
} else {
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
},
)
val detail =
if (error.isNotEmpty()) {
@@ -79,7 +79,7 @@ internal object FileEditSupport {
var added = 0
var removed = 0
for (line in diff.split("\n")) {
for (line in diff.lineSequence()) {
when {
line.startsWith("+") && !line.startsWith("+++") -> added += 1
line.startsWith("-") && !line.startsWith("---") -> removed += 1
@@ -105,14 +105,12 @@ internal object SkillManageRenderer : ToolRenderer {
val msg = ToolJson.firstString(call.result, listOf("message"))
val failed = ToolJson.boolFalse(call.result?.get("success"))
val succeeded = ToolJson.boolTrue(call.result?.get("success"))
// Pinned quirk from the original engine: the branch order is inverted
// (a non-false `success` renders the failure line). Preserved verbatim
// by the characterization suite; fix upstream deliberately if desired.
return when {
!failed -> "❌ Skill operation failed"
msg.isNotEmpty() -> "✅ $msg"
else -> "✅ Skill operation done"
failed -> "❌ ${msg.ifEmpty { "Skill operation failed" }}"
succeeded -> "✅ ${msg.ifEmpty { "Skill operation done" }}"
else -> "❌ ${msg.ifEmpty { "Skill operation failed" }}"
}
}
}
@@ -221,6 +221,11 @@ internal object ProcessRenderer : ToolRenderer {
return if (procId.isNotEmpty()) "$action: $procId" else action
}
override fun pendingDetail(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action"))
return "⏳ ${action.ifEmpty { "Process operation" }} in progress"
}
override fun detail(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
@@ -248,7 +253,7 @@ internal object ProcessRenderer : ToolRenderer {
}.joinToString("\n\n")
}
val output = ToolJson.firstString(result, listOf("output"))
val output = ToolJson.firstString(result, listOf("output", "output_preview"))
if (output.isNotEmpty()) {
val status = ToolJson.firstString(result, listOf("status"))
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output"
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -34,6 +38,69 @@ inline fun <T> ViewModel.safeLaunchLoad(
}
}
/**
* Executes a read-only API call with Stale-While-Revalidate caching.
* If cached data exists in [cache] for [cacheKey] and [forceRefresh] is false, [onCacheHit] is invoked immediately
* on the caller thread, avoiding full-screen loading spinners on screen revisits.
* The network call runs in the background to update the cache and invoke [onSuccess].
* If a previous request is active:
* - when [forceRefresh] is false, the active job is returned to avoid duplicate in-flight work
* - when [forceRefresh] is true, the active job is cancelled and superseded by the new forced refresh
* Results are checked against [requestScope] and coroutine cancellation so late-arriving responses
* cannot overwrite newer state.
*/
inline fun <T : Any> ViewModel.safeLaunchSwrLoad(
cache: SwrCache<String, T>,
cacheKey: String = "default",
forceRefresh: Boolean = false,
currentJob: Job? = null,
requestScope: DataScope? = runCatching { AuthManager.currentDataScope() }.getOrNull(),
crossinline scopeIsCurrent: () -> Boolean = {
requestScope == null || AuthManager.currentDataScope() == requestScope
},
crossinline onCacheHit: (T) -> Unit,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
): Job {
if (currentJob?.isActive == true) {
if (!forceRefresh) return currentJob
currentJob.cancel()
}
val scopedKey = requestScope?.scopedKey(cacheKey)
if (forceRefresh && scopedKey != null) {
cache.remove(scopedKey)
}
val cached = if (!forceRefresh && scopedKey != null) cache.get(scopedKey) else null
if (cached != null) {
onCacheHit(cached)
} else {
onStart()
}
return viewModelScope.launch {
val result = apiCall()
coroutineContext.ensureActive()
if (!scopeIsCurrent()) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
if (scopedKey != null) {
cache.put(scopedKey, result.data)
}
onSuccess(result.data)
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
onError(result.error.message)
}
}
}
}
}
/**
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
.collect { _ ->
if (refreshInFlight) return@collect
refreshInFlight = true
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
try {
// No Dispatchers.IO hop — see safeLaunchLoad.
val result = apiCall()
coroutineContext.ensureActive()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@collect
if (result is NetworkResult.Success) {
onSuccess(result.data)
}
@@ -99,7 +99,7 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAll() },
onRefresh = { viewModel.loadAll(forceRefresh = true) },
actions = {
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
IconButton(onClick = { viewModel.saveConfig() }) {
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -50,35 +54,114 @@ class ConfigViewModel :
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
private val pendingChanges = mutableMapOf<String, JsonElement>()
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
init {
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
// connection switch). An additional AuthManager.dataScopeFlow collector would
// double-fire on the same transition and issue a duplicate loadAll().
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
onDataScopeChanged(AuthManager.currentDataScope())
}
}
loadAll()
}
fun loadAll() {
viewModelScope.launch {
private fun onDataScopeChanged(newScope: DataScope?) {
pendingChanges.clear()
_uiState.update {
it.copy(
values = null,
schema = null,
defaults = null,
uncoveredPaths = emptyList(),
path = null,
yamlText = null,
modifiedKeys = emptySet(),
isSaving = false,
yamlIsSaving = false,
yamlIsLoading = false,
errorMessage = null,
)
}
// A scope switch must load the new context. The scoped caches mean this paints the
// new scope's own entry instantly when one exists, then revalidates in the background.
loadAll()
}
fun loadAll(forceRefresh: Boolean = false) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
if (forceRefresh && scopeKey.isNotBlank()) {
schemaCache.remove(scopeKey)
defaultsCache.remove(scopeKey)
}
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
val hasCachedData = _uiState.value.values != null
if (!hasCachedData) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try {
coroutineScope {
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
val schemaDeferred =
if (cachedSchema == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
} else {
null
}
val defaultsDeferred =
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
if (cachedDefaults == null) {
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
} else {
null
}
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await()
val defaultsResult = defaultsDeferred.await()
val schema: ConfigSchemaResponse? =
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
} ?: cachedSchema
val defaults: Map<String, JsonElement>? =
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
} ?: cachedDefaults
val rawResult = rawDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@coroutineScope
if (configResult is NetworkResult.Success) {
val values = flattenConfig(configResult.data)
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update {
it.copy(
_uiState.update { state ->
val categories = schema?.category_order ?: emptyList()
val validCategory =
when {
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
state.activeCategory
}
categories.isNotEmpty() -> {
categories.first()
}
else -> {
state.activeCategory
}
}
state.copy(
isLoading = false,
values = values,
schema = schema,
@@ -89,15 +172,11 @@ class ConfigViewModel :
schema?.fields?.keys ?: emptySet(),
),
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
activeCategory = validCategory,
errorMessage = null,
)
}
} else {
} else if (!hasCachedData) {
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
@@ -105,8 +184,11 @@ class ConfigViewModel :
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
}
}
}
}
@@ -198,6 +280,7 @@ class ConfigViewModel :
fun saveConfig() {
if (pendingChanges.isEmpty()) return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
val changeset = buildChangeset(pendingChanges)
@@ -209,6 +292,8 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
pendingChanges.clear()
@@ -216,6 +301,8 @@ class ConfigViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
isSaving = false,
@@ -242,6 +329,7 @@ class ConfigViewModel :
fun saveYamlConfig() {
val yamlText = _uiState.value.yamlText ?: return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(yamlIsSaving = true) }
viewModelScope.launch {
val result =
@@ -252,12 +340,16 @@ class ConfigViewModel :
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) {
is NetworkResult.Success -> {
val configResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() }
}
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update {
it.copy(
yamlIsSaving = false,
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob
@@ -82,10 +83,12 @@ fun CronJobsScreen(
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var selectedJob by remember { mutableStateOf<CronJob?>(null) }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadCronJobs()
}
@@ -95,7 +98,7 @@ fun CronJobsScreen(
title = { Text(stringResource(R.string.screen_cron)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() },
onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon(
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob
@@ -16,6 +19,7 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -95,6 +99,19 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
jobs = emptyList(),
errorMessage = null,
deleteTarget = null,
editorState = CronJobEditorState(),
)
}
}
init {
// Issue #784: gateway broadcasts cron.changed — refresh the list
@@ -102,14 +119,24 @@ class CronJobsViewModel :
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
onSuccess = { data ->
val jobs = data.orEmpty()
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null) jobsCache.put(requestScope.inMemoryKey("default"), jobs)
_uiState.update { it.copy(jobs = jobs) }
},
)
}
fun loadCronJobs() {
fun loadCronJobs(forceRefresh: Boolean = false) {
loadJob =
safeLaunchLoad(
safeLaunchSwrLoad(
cache = jobsCache,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -128,6 +155,7 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -141,14 +169,24 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(
jobs =
@@ -162,8 +200,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -188,6 +235,7 @@ class CronJobsViewModel :
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
@@ -196,8 +244,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
}
if (result is NetworkResult.Failure) {
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
if (result is NetworkResult.Success) {
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
} else if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
if (requestScope != null) {
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
}
}
}
}
@@ -447,7 +504,7 @@ class CronJobsViewModel :
when (result) {
is NetworkResult.Success -> {
closeEditor()
loadCronJobs()
loadCronJobs(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -79,6 +81,7 @@ fun FilesScreen(
viewModel: FilesViewModel = viewModel { FilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val uploadLauncher =
@@ -104,6 +107,13 @@ fun FilesScreen(
onDispose { viewModel.clearTransientState() }
}
// Scope-aware load: a new server/profile context must never keep the old
// server's directory path or entries on screen (PR #1192 cache scoping).
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.load()
}
val hasEntries = state.entries.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
import android.util.Base64
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
import com.m57.hermescontrol.data.model.ManagedFileDelete
import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -76,8 +79,52 @@ class FilesViewModel :
private val _uiState = MutableStateFlow(FilesUiState())
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
fun load(path: String? = null) {
safeLaunchLoad(
private val filesCache = SwrCache<String, ManagedFilesListResponse>()
/**
* Reset state owned by the previous server/profile context (issue: PR #1192 cache scoping).
* The current directory path belongs to the old server and must not leak into the new one.
*/
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
currentPath = "",
parentPath = null,
crumbs = emptyList(),
entries = emptyList(),
isLoading = false,
errorMessage = null,
busyPaths = emptySet(),
isCreatingDir = false,
newDirName = "",
isUploading = false,
deleteTarget = null,
openingPath = null,
)
}
}
fun load(
path: String? = null,
forceRefresh: Boolean = false,
) {
val cacheKey = path ?: ""
safeLaunchSwrLoad(
cache = filesCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { state ->
state.copy(
isLoading = false,
currentPath = data.path,
parentPath = data.parent,
crumbs = buildCrumbs(data.path),
entries = data.entries,
errorMessage = null,
)
}
},
apiCall = {
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
},
@@ -104,7 +151,7 @@ class FilesViewModel :
)
}
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
fun refresh() = load(_uiState.value.currentPath.ifBlank { null }, forceRefresh = true)
fun navigateTo(entry: ManagedFileEntry) {
if (!entry.isDirectory) return
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -51,9 +52,11 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadStatus()
}
@@ -63,7 +66,7 @@ fun GatewayScreen(
title = { Text(stringResource(R.string.screen_gateway)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadStatus() },
onRefresh = { viewModel.loadStatus(forceRefresh = true) },
) { paddingValues ->
when {
state.isLoading && state.status == null -> {
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,8 +30,19 @@ class GatewayViewModel :
private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() {
safeLaunchLoad(
private val statusCache = SwrCache<String, StatusResponse>()
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, status = null, errorMessage = null) }
}
fun loadStatus(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = statusCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update { it.copy(isLoading = false, status = data, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -73,7 +86,7 @@ class GatewayViewModel :
toastMessage = "Gateway ${actionName}ed successfully",
)
}
loadStatus()
loadStatus(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject
@@ -32,7 +33,10 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -168,34 +172,71 @@ class KanbanViewModel(
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val hasExistingContent = _uiState.value.columns.isNotEmpty() || _uiState.value.tasks.isNotEmpty()
if (!hasExistingContent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch {
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
coroutineScope {
val prefetchedBoardDeferred =
if (previouslySelectedId != null) {
async(Dispatchers.IO) {
repository.getBoard(
board = previouslySelectedId,
includeArchived = _uiState.value.includeArchived,
)
}
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
null
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
_uiState.update { it.copy(boards = boards) }
if (targetBoard != null) {
if (prefetchedBoardDeferred != null && targetBoard.id == previouslySelectedId) {
preferences.setSelectedBoard(endpoint, targetBoard.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = targetBoard) }
val prefetchedResult = prefetchedBoardDeferred.await()
applyBoardResult(targetBoard, prefetchedResult, gen)
connectEvents(targetBoard)
} else {
selectBoard(targetBoard)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
selectedBoard = null,
columns = emptyList(),
tasks = emptyList(),
)
}
}
}
is NetworkResult.Failure -> {
if (!hasExistingContent) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
}
}
}
@@ -906,15 +947,11 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
private fun applyBoardResult(
board: KanbanBoard,
gen: Int = currentLoadGen,
result: NetworkResult<KanbanBoardResponse>,
gen: Int,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
@@ -928,6 +965,7 @@ class KanbanViewModel(
isLoading = false,
columns = body.columns,
tasks = allTasks,
errorMessage = null,
)
}
}
@@ -948,6 +986,18 @@ class KanbanViewModel(
}
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
applyBoardResult(board, result, gen)
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
@@ -114,6 +115,7 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
@@ -150,7 +152,8 @@ fun KeysScreen(
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadKeys()
}
@@ -211,7 +214,7 @@ fun KeysScreen(
title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() },
onRefresh = { viewModel.loadKeys(forceRefresh = true) },
actions = {
IconButton(onClick = { viewModel.openAddDialog() }) {
Icon(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,6 +57,19 @@ class KeysViewModel :
private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
private val keysCache = SwrCache<String, Map<String, EnvVarConfig>>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
categories = emptyList(),
revealedValues = emptyMap(),
errorMessage = null,
)
}
}
private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> =
@@ -89,8 +104,21 @@ class KeysViewModel :
}
}
fun loadKeys() {
safeLaunchLoad(
fun loadKeys(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = keysCache,
forceRefresh = forceRefresh,
onCacheHit = { envVars ->
val currentCategories = _uiState.value.categories
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
_uiState.update {
it.copy(
isLoading = false,
categories = buildCategoryList(envVars, expandedCategories.keys),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -178,7 +206,7 @@ class KeysViewModel :
toastMessage = "Key added successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -223,7 +251,7 @@ class KeysViewModel :
toastMessage = "Key deleted successfully",
)
}
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -281,7 +309,7 @@ class KeysViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
loadKeys()
loadKeys(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
@@ -70,6 +71,7 @@ fun LogsScreen(
viewModel: LogsViewModel = viewModel { LogsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val listState = rememberLazyListState()
@@ -115,7 +117,8 @@ fun LogsScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadLogs()
}
@@ -146,7 +149,7 @@ fun LogsScreen(
title = { Text(stringResource(R.string.screen_logs)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadLogs() },
onRefresh = { viewModel.loadLogs(forceRefresh = true) },
actions = {
// Pause / Resume toggle
IconButton(onClick = { pauseScroll = !pauseScroll }) {
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,43 +42,51 @@ class LogsViewModel :
private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
@Volatile
private var loadInProgress = false
private var loadJob: Job? = null
private var autoRefreshJob: Job? = null
private val logsCache = SwrCache<String, LogResponse>()
fun loadLogs() {
if (loadInProgress) return
loadInProgress = true
fun clearScopeOwnedState() {
_uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
}
fun loadLogs(forceRefresh: Boolean = false) {
val filters = _uiState.value.filters
safeLaunchLoad(
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val body = data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
loadInProgress = false
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
loadInProgress = false
},
)
val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
loadJob =
safeLaunchSwrLoad(
cache = logsCache,
cacheKey = cacheKey,
forceRefresh = forceRefresh,
currentJob = loadJob,
onCacheHit = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
},
apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val logsList = data.lines ?: data.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
}
},
)
}
/** Update the server-side filters and immediately reload. */
@@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing
@@ -99,6 +100,7 @@ fun McpServersScreen(
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
@@ -127,14 +129,17 @@ fun McpServersScreen(
}
}
LaunchedEffect(Unit) { viewModel.loadServers() }
LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadServers()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers() },
onRefresh = { viewModel.loadServers(forceRefresh = true) },
actions = {
IconButton(
onClick = { viewModel.testAllServers() },
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
@@ -9,11 +10,13 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -69,10 +72,39 @@ class McpServersViewModel :
private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
private val mcpCache = SwrCache<String, McpServersResponse>()
fun clearScopeOwnedState() {
_uiState.update {
it.copy(
isLoading = false,
servers = emptyList(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
isTestingAll = false,
errorMessage = null,
activeOAuthFlow = null,
)
}
}
// ── Data loading ──────────────────────────────────────────
fun loadServers() {
safeLaunchLoad(
fun loadServers(forceRefresh: Boolean = false) {
safeLaunchSwrLoad(
cache = mcpCache,
forceRefresh = forceRefresh,
onCacheHit = { data ->
_uiState.update {
it.copy(
isLoading = false,
servers = data.servers.orEmpty(),
serverTestResults = emptyMap(),
testingServers = emptySet(),
errorMessage = null,
)
}
},
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
@@ -247,7 +279,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -305,7 +337,9 @@ class McpServersViewModel :
toastMessage = msg,
)
}
loadServers()
if (successCount > 0) {
loadServers(forceRefresh = true)
}
}
}
@@ -371,7 +405,7 @@ class McpServersViewModel :
toastMessage = "Server '${request.name}' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -428,7 +462,7 @@ class McpServersViewModel :
toastMessage = "Env var '$key' added",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -453,7 +487,7 @@ class McpServersViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -520,7 +554,7 @@ class McpServersViewModel :
toastMessage = "Catalog entry '${entry.name}' installed",
)
}
loadServers()
loadServers(forceRefresh = true)
}
is NetworkResult.Failure -> {
@@ -605,7 +639,7 @@ class McpServersViewModel :
toastMessage = "OAuth authorization successful!",
)
}
loadServers()
loadServers(forceRefresh = true)
}
"error" -> {
@@ -112,7 +112,7 @@ fun MemoryScreen(
title = { Text(stringResource(R.string.screen_memory)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
onRefresh = { viewModel.load(forceRefresh = true) },
modifier = modifier,
) { paddingValues ->
when {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResponse
@@ -10,6 +13,9 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -39,50 +45,89 @@ class MemoryViewModel :
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
init {
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
// connection switch). An additional AuthManager.dataScopeFlow collector would
// double-fire on the same transition and issue a duplicate request, so the
// coordinator flows remain the single trigger.
viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect {
load(silent = true)
onDataScopeChanged(AuthManager.currentDataScope())
}
}
}
fun load(silent: Boolean = false) {
private var loadJob: Job? = null
private val memoryCache = SwrCache<String, MemoryResponse>()
private fun onDataScopeChanged(newScope: DataScope?) {
loadJob?.cancel()
_uiState.update {
it.copy(
isLoading = false,
memory = null,
learningGraph = null,
resetting = null,
errorMessage = null,
)
}
// A scope switch must silently reload the new context's data. Clearing alone
// would leave the screen empty when it is not the composition performing the load.
load(silent = true)
}
fun load(
silent: Boolean = false,
forceRefresh: Boolean = false,
) {
if (_uiState.value.isLoading) return
if (!silent) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val scopedKey = requestScope?.inMemoryKey("default")
if (forceRefresh && scopedKey != null) memoryCache.remove(scopedKey)
val cached = if (!forceRefresh && scopedKey != null) memoryCache.get(scopedKey) else null
if (cached != null) {
_uiState.update { it.copy(isLoading = false, memory = cached, errorMessage = null) }
} else if (!silent) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) }
}
loadJob =
viewModelScope.launch {
coroutineScope {
val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
val memoryResult = memoryDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (currentScope != requestScope) return@coroutineScope
when (memoryResult) {
is NetworkResult.Success -> {
if (scopedKey != null) memoryCache.put(scopedKey, memoryResult.data)
_uiState.update { it.copy(isLoading = false, memory = memoryResult.data) }
}
is NetworkResult.Failure -> {
if (cached == null || forceRefresh) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = memoryResult.error.message,
)
}
}
}
}
val graphResult = graphDeferred.await()
if (graphResult is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = graphResult.data) }
}
}
}
loadLearningGraph()
}
}
private fun loadLearningGraph() {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = result.data) }
}
}
}
fun resetMemory(target: String) {
@@ -109,7 +154,7 @@ class MemoryViewModel :
}
}
}
load()
load(forceRefresh = true)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -98,15 +100,15 @@ fun ModelScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_models)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
isRefreshing = state.isLoading || state.catalogLoading,
onRefresh = { viewModel.loadAll(refresh = true) },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() },
@@ -114,7 +116,10 @@ fun ModelScreen(
)
}
state.providers.isEmpty() -> {
!state.isLoading &&
!state.catalogLoading &&
state.providers.isEmpty() &&
state.mainModelProvider.isEmpty() -> {
EmptyState(
title = stringResource(R.string.model_empty_title),
subtitle = stringResource(R.string.model_empty_desc),
@@ -191,24 +196,38 @@ fun ModelScreen(
}
// ── Provider List ──
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
if (state.catalogLoading && state.providers.isEmpty()) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
} else {
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
ProviderCard(
provider = provider,
isExpanded = isExpanded,
isCurrent = isCurrent,
query = query,
activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels,
onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
)
}
}
}
}
@@ -241,7 +260,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_main),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
@@ -267,7 +286,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = stringResource(R.string.model_set_aux, state.auxPickerTask),
isLoading = state.isLoading && state.providers.isEmpty(),
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more