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] needs: [changes]
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 15
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 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*`)._ _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 ## Authentication
+5
View File
@@ -176,6 +176,11 @@ dependencies {
implementation(libs.coil.gif) implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
// Media3 (ExoPlayer)
implementation(libs.media3.exoplayer)
implementation(libs.media3.ui)
implementation(libs.media3.datasource.okhttp)
// Encrypted storage // Encrypted storage
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore) 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, composerWidth: Dp? = null,
modelAfterTap: String? = null, modelAfterTap: String? = null,
layoutDirection: LayoutDirection = LayoutDirection.Ltr, layoutDirection: LayoutDirection = LayoutDirection.Ltr,
showModelProvider: Boolean = false,
) { ) {
composeTestRule.setContent { composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) } var input by remember { mutableStateOf(TextFieldValue("")) }
@@ -81,6 +82,7 @@ class ComposerInteractionTest {
isListening = listening, isListening = listening,
isAgentTyping = false, isAgentTyping = false,
isConnected = true, isConnected = true,
isSessionReady = true,
commandCatalog = CommandCatalog(), commandCatalog = CommandCatalog(),
currentSessionModel = currentModel, currentSessionModel = currentModel,
reasoningLevel = reasoningLevel, reasoningLevel = reasoningLevel,
@@ -92,6 +94,7 @@ class ComposerInteractionTest {
onCameraTap = { cameraTaps++ }, onCameraTap = { cameraTaps++ },
onImageTap = { photosTaps++ }, onImageTap = { photosTaps++ },
onFileTap = { fileTaps++ }, onFileTap = { fileTaps++ },
showModelProvider = showModelProvider,
) )
} }
if (composerWidth == null) { if (composerWidth == null) {
@@ -205,9 +208,9 @@ class ComposerInteractionTest {
} }
@Test @Test
fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() { fun longModelName_remainsScrollableAndDoesNotTriggerPicker() {
val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" 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("reasoning_chip").assertIsDisplayed()
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
@@ -251,7 +254,7 @@ class ComposerInteractionTest {
fun changingModel_resetsScrolledLabelToTheStart() { fun changingModel_resetsScrolledLabelToTheStart() {
val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" 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" 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 firstText = composeTestRule.onNodeWithText(firstModel)
val leftBefore = firstText.getUnclippedBoundsInRoot().left val leftBefore = firstText.getUnclippedBoundsInRoot().left
@@ -343,4 +346,29 @@ class ComposerInteractionTest {
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() 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.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import com.m57.hermescontrol.data.local.AuthManager 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.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader 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 : class HermesControlApp :
Application(), Application(),
SingletonImageLoader.Factory { SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AuthManager.init(this) 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) NetworkMonitor.init(this)
// Issue #537 follow-up (A): preload analytics in the background after launch SessionListCacheStore.init(this)
// so the tab renders instantly when opened (the usage endpoint is slow on a // Reply-notification turn boundaries survive process death (armed before
// cold backend). Fire-and-forget; never blocks UI startup. // each mobile prompt, consumed by the matching completion).
AnalyticsPreloader.preload(this) TurnCorrelationTracker.attach(this)
// Issue #890: silent once-per-version update check right after launch, appScope.launch {
// so the chat screen can show an update banner without user interaction. AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
UpdateNoticeManager.checkOnLaunch() // 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 = override fun newImageLoader(context: PlatformContext): ImageLoader =
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier 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.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme 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 com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION" "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. * Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store * Reads the persisted code from [AuthManager]; an uninitialized store
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
val code = val code =
runCatching { AuthManager.getAppLanguage() } runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE) .getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
baseContextLanguageCode = code
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code)) super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
} }
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
consumeNotificationIntent(intent) 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() enableEdgeToEdge()
setContent { setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState() val themePreference by AuthManager.themePreferenceFlow.collectAsState()
@@ -65,7 +89,35 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, 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) NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed() ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this) NotificationHelper.stop(this)
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
} }
override fun onPause() { override fun onPause() {
@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
class ServerStore( class ServerStore private constructor(
private val dataStore: DataStore<ServerStoreState>, private val dataStore: DataStore<ServerStoreState>,
private val scope: CoroutineScope, private val scope: CoroutineScope,
initial: ServerStoreState,
) { ) {
private val _stateFlow: MutableStateFlow<ServerStoreState> private val _stateFlow = MutableStateFlow(initial)
val stateFlow: StateFlow<ServerStoreState> val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
init { companion object {
val initial = // Do not expose a default snapshot before disk/migrations finish (issue #1171).
runBlocking(Dispatchers.IO) { suspend fun create(
try { dataStore: DataStore<ServerStoreState>,
dataStore.data.first().selfHealed() scope: CoroutineScope,
} catch (e: Exception) { ): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
ServerStoreSerializer.defaultValue
}
}
_stateFlow = MutableStateFlow(initial)
stateFlow = _stateFlow.asStateFlow()
} }
fun getLatestState(): ServerStoreState = _stateFlow.value fun getLatestState(): ServerStoreState = _stateFlow.value
@@ -28,6 +28,11 @@ data class ServerStoreState(
val showUserMessageTokens: Boolean = true, val showUserMessageTokens: Boolean = true,
val showAssistantMessageTokens: Boolean = true, val showAssistantMessageTokens: Boolean = true,
val showTokensPerSecond: 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 // App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system", 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.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
/** /**
* Singleton that manages encrypted storage of the Hermes dashboard token * Singleton that manages encrypted storage of the Hermes dashboard token
@@ -124,37 +127,83 @@ object AuthManager {
initialValue = null, 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. * Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate(). * 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) { fun init(context: Context) {
if (_serverStore != null) return
synchronized(this) { synchronized(this) {
if (_serverStore != null) return if (initialization != null && _initializationState.value != InitializationState.Failed) return
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
val dataStore = appScope?.cancel()
androidx.datastore.core.DataStoreFactory.create( val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
serializer = ServerStoreSerializer,
migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) {
context.filesDir.resolve("server_store.json")
}
val scope = CoroutineScope(Dispatchers.IO)
appScope = scope appScope = scope
val store = ServerStore(dataStore, scope) _initializationState.value = InitializationState.Loading
_serverStore = store readyPrefs = null
_selectedProfileFlow.value = store.getLatestState().selectedProfileId val prefs = CompletableDeferred<SharedPreferences>(scope.coroutineContext[kotlinx.coroutines.Job])
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl prefsDeferred = prefs
initialization =
if (prefsDeferred == null) { scope.async {
prefsDeferred = try {
CoroutineScope(Dispatchers.IO).async { 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 masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val p = val p =
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
@@ -165,52 +214,60 @@ object AuthManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
migrateLegacyDefaultIfNeeded(p) migrateLegacyDefaultIfNeeded(p)
_tokenFlow.value = getTokenInternal(p) synchronized(this@AuthManager) {
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() } readyPrefs = p
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) }
} }
}
} }
} }
/** /** Suspend without blocking the caller or imposing a keystore startup deadline. */
* Retrieves the initialized [SharedPreferences] instance. suspend fun awaitInitialization() {
* checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
* 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. internal suspend fun awaitPrefs(): SharedPreferences {
* awaitInitialization()
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds. return requirePrefs()
*/ }
/** Synchronous APIs are ready-only; entry points must await initialization first. */
private fun requirePrefs(): SharedPreferences = private fun requirePrefs(): SharedPreferences =
runBlocking { checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
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.")
}
fun setWsAuthParam(param: String) { fun setWsAuthParam(param: String) {
serverStore.update { it.copy(wsAuthParam = param) } serverStore.update { it.copy(wsAuthParam = param) }
@@ -259,18 +316,34 @@ object AuthManager {
// ── Database Master Password ───────────────────────────────────────── // ── Database Master Password ─────────────────────────────────────────
fun getDatabasePassword(): ByteArray { suspend fun getDatabasePassword(): ByteArray =
val prefs = requirePrefs() withContext(Dispatchers.IO) {
var dbPasswordBase64 = prefs.getString("db_password", null) val prefs = awaitPrefs()
if (dbPasswordBase64 == null) { synchronized(this@AuthManager) {
val random = java.security.SecureRandom() cachedDatabasePassword?.let { return@synchronized it.copyOf() }
val newPassword = ByteArray(32) val stored = prefs.getString("db_password", null)
random.nextBytes(newPassword) val password =
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP) if (stored == null) {
prefs.edit().putString("db_password", dbPasswordBase64).apply() 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 ────────────────────────────────────────────── // ── Connection Profiles ──────────────────────────────────────────────
@@ -387,15 +460,15 @@ object AuthManager {
token: String?, token: String?,
) { ) {
requirePrefs().edit().putString("token_$profileId", token).apply() requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) { if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
if (token == null) ActiveSessionHolder.clear() if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken // B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh // to prevent stale tokens during ticket refresh
synchronized(this) { synchronized(this) {
cachedToken = token cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
tokenInitialized = true tokenInitialized = true
_tokenFlow.value = cachedToken
} }
_tokenFlow.value = token
} }
} }
@@ -439,6 +512,9 @@ object AuthManager {
*/ */
fun setActiveProfileId(id: String?) { fun setActiveProfileId(id: String?) {
val normalized = id?.takeIf { it.isNotBlank() } 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 _activeProfileId.value = normalized
runCatching { runCatching {
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply() 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 // For testing purposes
fun resetAuthStateForTest() { fun resetAuthStateForTest() {
synchronized(this) { synchronized(this) {
@@ -489,6 +572,14 @@ object AuthManager {
tokenInitialized = false tokenInitialized = false
_serverStore = null _serverStore = null
prefsDeferred = 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 { appScope?.let {
try { try {
it.cancel() it.cancel()
@@ -539,6 +630,9 @@ object AuthManager {
} }
fun setToken(token: String?) { fun setToken(token: String?) {
if (token == null) {
invalidateAuthGeneration()
}
val selectedId = val selectedId =
getSelectedProfileId() ?: run { getSelectedProfileId() ?: run {
ensureDefaultSelected() ensureDefaultSelected()
@@ -555,6 +649,23 @@ object AuthManager {
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl 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 = fun endpoint(): ServerEndpoint =
ServerEndpoint.parse( ServerEndpoint.parse(
getBaseUrl(), getBaseUrl(),
@@ -739,6 +850,18 @@ object AuthManager {
serverStore.update { it.copy(showTokensPerSecond = enabled) } 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) ─────────────────────────────────── // ── Chat Font Scale (issue #1004) ───────────────────────────────────
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
@@ -750,6 +873,16 @@ object AuthManager {
// ── In-app update check (issue #867) ───────────────────────────────── // ── 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). */ /** App version the silent update check last completed for (null = never). */
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
@@ -46,4 +46,6 @@ data class ChatMessageEntity(
val tokenCount: Int? = null, val tokenCount: Int? = null,
@ColumnInfo(name = "tps") @ColumnInfo(name = "tps")
val tps: Double? = null, val tps: Double? = null,
@ColumnInfo(name = "completion_id")
val completionId: String? = null,
) )
@@ -37,6 +37,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
displayKind = displayKind, displayKind = displayKind,
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, tps = tps,
completionId = completionId,
) )
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -54,4 +55,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
displayKind = displayKind, displayKind = displayKind,
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, 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.room3.migration.Migration
import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL import androidx.sqlite.execSQL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import java.io.File import java.io.File
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 7, version = 8,
exportSchema = true, exportSchema = true,
) )
abstract class HermesDatabase : RoomDatabase() { abstract class HermesDatabase : RoomDatabase() {
@@ -79,40 +81,55 @@ abstract class HermesDatabase : RoomDatabase() {
} }
} }
fun get(context: Context): HermesDatabase = val MIGRATION_7_8: Migration =
instance ?: synchronized(this) { object : Migration(7, 8) {
// SQLCipher can't open plaintext SQLite databases — if an old override suspend fun migrate(connection: SQLiteConnection) {
// unencrypted DB exists (v1), delete it so Room + SQLCipher can connection.execSQL(
// create an encrypted replacement from scratch. "ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
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,
) )
}
}
instance ?: Room suspend fun get(context: Context): HermesDatabase =
.databaseBuilder( withContext(Dispatchers.IO) {
context.applicationContext, instance?.let { return@withContext it }
HermesDatabase::class.java, val password = AuthManager.getDatabasePassword()
"hermes_control.db", synchronized(this@Companion) {
).setDriver(driver) instance?.let { return@synchronized it }
.addMigrations( // SQLCipher can't open plaintext SQLite databases — if an old
MIGRATION_2_3, // unencrypted DB exists (v1), delete it so Room + SQLCipher can
MIGRATION_3_4, // create an encrypted replacement from scratch.
MIGRATION_4_5, val dbFile = context.getDatabasePath("hermes_control.db")
MIGRATION_5_6, if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
MIGRATION_6_7, dbFile.delete()
).fallbackToDestructiveMigration(false) }
.build()
.also { instance = it } // 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. */ /** 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 package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -25,22 +27,23 @@ data class Attachment(
val gatewayUrl: String? = null, val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL, val source: AttachmentSource = AttachmentSource.LOCAL,
) { ) {
val mediaKind: MediaKind
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
val isImage: Boolean val isImage: Boolean
get() = mimeType.startsWith("image/") get() = mediaKind == MediaKind.IMAGE
val isGif: Boolean val isGif: Boolean
get() = get() =
mimeType.equals("image/gif", ignoreCase = true) || mimeType.equals("image/gif", ignoreCase = true) ||
fileExtension == "gif" || fileExtension == "gif" ||
uri.contains(".gif", ignoreCase = true) ||
uri.startsWith("data:image/gif", ignoreCase = true) uri.startsWith("data:image/gif", ignoreCase = true)
val isVideo: Boolean val isVideo: Boolean
get() = get() = mediaKind == MediaKind.VIDEO
mimeType.startsWith("video/") ||
fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || val isAudio: Boolean
uri.contains(".mp4", ignoreCase = true) || get() = mediaKind == MediaKind.AUDIO
uri.contains(".webm", ignoreCase = true)
val isGateway: Boolean val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY get() = source == AttachmentSource.GATEWAY
@@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SessionSearchResponse( data class SessionSearchResponse(
val results: List<SessionSearchResult> = emptyList(), val results: List<SessionSearchResult> = emptyList(),
) val has_more: Boolean? = null,
val next_offset: Int? = null,
) {
val hasMore: Boolean get() = has_more == true
}
@Serializable @Serializable
data class SessionSearchResult( data class SessionSearchResult(
@@ -15,4 +19,9 @@ data class SessionSearchResult(
val source: String? = null, val source: String? = null,
val model: String? = null, val model: String? = null,
val session_started: Double? = 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 com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import okio.Buffer import okio.Buffer
internal object DashboardSessionTokenRefresher { internal object DashboardSessionTokenRefresher {
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""") private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
private val refreshMutex = Mutex()
// Synchronized so concurrent 401s / reconnect storms don't each fire a // Synchronized so concurrent 401s / reconnect storms don't each fire a
// redundant GET at the dashboard SPA (thundering-herd guard). // redundant GET at the dashboard SPA (thundering-herd guard).
@Synchronized @Synchronized
fun refresh(): String? = fun refresh(): String? =
try { try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
} catch (_: Exception) { } catch (_: Exception) {
null 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, baseUrl: String,
client: OkHttpClient, client: OkHttpClient,
): String? = ): String? =
@@ -32,32 +70,31 @@ internal object DashboardSessionTokenRefresher {
.url(baseUrl) .url(baseUrl)
.get() .get()
.build() .build()
// execute() + body read on Dispatchers.IO: the body read touches
// the socket on the CALLING thread otherwise — when that is main client.newCall(request).execute().use { response ->
// (loopback ticket mint from the kanban events connect), it throws if (!response.isSuccessful) return null
// NetworkOnMainThreadException (same bug class as requestWsTicket). extractToken(response)
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@runBlocking null
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
} }
} catch (_: Exception) { } catch (_: Exception) {
null 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 private const val MAX_BODY_BYTES: Long = 64 * 1024
} }
@@ -277,6 +277,7 @@ object GatewayFileClient {
coroutineContext.ensureActive() coroutineContext.ensureActive()
val read = input.read(buffer) val read = input.read(buffer)
if (read < 0) break if (read < 0) break
coroutineContext.ensureActive()
output.write(buffer, 0, read) output.write(buffer, 0, read)
} }
} }
@@ -189,6 +189,8 @@ interface HermesApiService : KanbanApiService {
@Query("profile") profile: String? = null, @Query("profile") profile: String? = null,
@Query("source") source: String? = null, @Query("source") source: String? = null,
@Query("exclude_sources") excludeSources: String? = null, @Query("exclude_sources") excludeSources: String? = null,
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0,
): Response<SessionSearchResponse> ): Response<SessionSearchResponse>
@GET("api/sessions/{id}/messages") @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.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -19,6 +22,10 @@ object NetworkMonitor {
private val networkChangeEvents = MutableSharedFlow<Boolean>(extraBufferCapacity = Int.MAX_VALUE) private val networkChangeEvents = MutableSharedFlow<Boolean>(extraBufferCapacity = Int.MAX_VALUE)
internal val networkChanges: SharedFlow<Boolean> = networkChangeEvents.asSharedFlow() 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 callbackLock = Any()
private val callbackGeneration = AtomicInteger(0) private val callbackGeneration = AtomicInteger(0)
private var callbackRegistration: Pair<ConnectivityManager, ConnectivityManager.NetworkCallback>? = null private var callbackRegistration: Pair<ConnectivityManager, ConnectivityManager.NetworkCallback>? = null
@@ -26,6 +33,9 @@ object NetworkMonitor {
@Volatile @Volatile
private var defaultNetwork: Network? = null private var defaultNetwork: Network? = null
@Volatile
private var currentTransports: Set<Int>? = null
fun init(context: Context) { fun init(context: Context) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = createCallback(cm) val callback = createCallback(cm)
@@ -38,6 +48,10 @@ object NetworkMonitor {
defaultNetwork = activeNetwork defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null networkAvailable = activeNetwork != null
_isConnected.value = networkAvailable _isConnected.value = networkAvailable
currentTransports =
runCatching {
activeNetwork?.let { cm.getNetworkCapabilities(it) }?.let { extractTransports(it) }
}.getOrNull()
callbackRegistration.also { callbackRegistration = cm to callback } callbackRegistration.also { callbackRegistration = cm to callback }
} }
if (networkChanged) networkChangeEvents.tryEmit(networkAvailable) if (networkChanged) networkChangeEvents.tryEmit(networkAvailable)
@@ -56,6 +70,7 @@ object NetworkMonitor {
if (callbackGeneration.get() != generation) return if (callbackGeneration.get() != generation) return
(defaultNetwork != network).also { (defaultNetwork != network).also {
defaultNetwork = network defaultNetwork = network
currentTransports = null
_isConnected.value = true _isConnected.value = true
} }
} }
@@ -71,11 +86,86 @@ object NetworkMonitor {
(defaultNetwork != activeNetwork).also { (defaultNetwork != activeNetwork).also {
defaultNetwork = activeNetwork defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null networkAvailable = activeNetwork != null
currentTransports = null
_isConnected.value = networkAvailable _isConnected.value = networkAvailable
} }
} }
if (changed) networkChangeEvents.tryEmit(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 package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
} }
val backoff = jitteredBackoff(500L * (1 shl attempt)) val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff) delay(backoff)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
return NetworkResult.Failure( return NetworkResult.Failure(
NetworkError.Unknown( 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow 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. * 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
*/ */
object ProfileSwitchCoordinator { 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) private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
val switched: SharedFlow<String> = _switched.asSharedFlow() val switched: SharedFlow<String> = _switched.asSharedFlow()
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
suspend fun switchProfile(name: String): NetworkResult<Unit> { suspend fun switchProfile(name: String): NetworkResult<Unit> {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) } safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
} }
if (result !is NetworkResult.Success) return result if (result !is NetworkResult.Success) return result
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
// run off the main thread or the dial crashes with // run off the main thread or the dial crashes with
// NetworkOnMainThreadException and falls back to the 1s reconnect // NetworkOnMainThreadException and falls back to the 1s reconnect
// retry (visible in the 2026-08-06 live logcat). // retry (visible in the 2026-08-06 live logcat).
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
HermesWsClient.disconnect() HermesWsClient.disconnect()
HermesWsClient.connect() HermesWsClient.connect()
} }
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
AuthManager.setSelectedProfileId(profileId) AuthManager.setSelectedProfileId(profileId)
ApiClient.rebuild() ApiClient.rebuild()
_connectionSwitched.emit(profileId.orEmpty()) _connectionSwitched.emit(profileId.orEmpty())
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
// The WS ticket mint reads the cookie jar's ACTIVE store; the // The WS ticket mint reads the cookie jar's ACTIVE store; the
// selection change swaps that store asynchronously, so a dial // selection change swaps that store asynchronously, so a dial
// that races it mints with the PREVIOUS server's cookie → 401 → // that races it mints with the PREVIOUS server's cookie → 401 →
@@ -18,6 +18,9 @@ import java.io.IOException
data class UpdateInfo( data class UpdateInfo(
val tagName: String = "", val tagName: String = "",
val body: 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(), val assets: List<Asset> = emptyList(),
) { ) {
@Serializable @Serializable
@@ -36,33 +39,105 @@ data class UpdateInfo(
fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v") fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v")
/** /**
* True when [latest] is strictly newer than [current]. Numeric dot-segment * True when [latest] is strictly newer than [current] (issue #867).
* comparison ("1.21.0" > "1.2"). Non-numeric suffixes (e.g. "1.0-dev") * Compare the numeric core first, padding missing parts with zero, then
* compare as 0; an unparseable version never claims an update exists. * 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( fun isNewerVersion(
latest: String, latest: String,
current: String, current: String,
): Boolean { ): Boolean {
val a = versionSegments(latest) ?: return false val a = parseVersion(latest) ?: return false
val b = versionSegments(current) ?: return false val b = parseVersion(current) ?: return false
for (i in 0 until maxOf(a.size, b.size)) { for (i in 0 until maxOf(a.core.size, b.core.size)) {
val x = a.getOrElse(i) { 0 } val comparison = a.core.getOrElse(i) { 0 }.compareTo(b.core.getOrElse(i) { 0 })
val y = b.getOrElse(i) { 0 } if (comparison != 0) return comparison > 0
if (x != y) return x > y
} }
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) * Pick the newest installable release out of a GitHub `releases` list payload.
if (cleaned.isBlank()) return null *
return cleaned.split('.').map { segment -> * Drafts and releases without an APK asset are always ignored. Pre-releases are
// Leading numeric portion of each segment: "0-dev" → 0, "21" → 21. * only eligible when [includeReleaseCandidates] is set *and* the tag is a
segment.takeWhile { it.isDigit() }.toIntOrNull() ?: 0 * 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 * 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 * #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( open class AppUpdateChecker(
private val client: OkHttpClient = OkHttpProvider.base, 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 * Fetch the newest installable release metadata.
* release yet (404) or the body can't be parsed. Throws [IOException] *
* on network failure so the caller can surface a friendly error. * 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) { withContext(Dispatchers.IO) {
val request = if (includeReleaseCandidates) {
Request val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
.Builder() selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest") } else {
.header("Accept", "application/vnd.github+json") val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body.string().orEmpty()
parseUpdateInfo(body) 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 * Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted) * [onProgress]. True on success; false (with the partial file deleted)
@@ -134,6 +226,9 @@ open class AppUpdateChecker(
private companion object { private companion object {
const val DEFAULT_BUFFER_SIZE = 8192 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 { runCatching {
OkHttpProvider.json.decodeFromString<UpdateInfo>(json) OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
}.getOrNull() }.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.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return 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) { scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion) AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now) AuthManager.setLastUpdateCheckTimestamp(now)
val result = val result =
try { try {
checker.fetchLatestRelease() checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) { } catch (e: IOException) {
return@launch return@launch
} catch (e: Exception) { } catch (e: Exception) {
return@launch return@launch
} }
val info = result ?: 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 apk = info.apkAsset ?: return@launch
val state = val state =
if (isNewerVersion(info.tagName, currentVersion)) { if (isNewerVersion(info.tagName, currentVersion)) {
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
/** /**
* The tag the chat banner should advertise, or null when nothing newer is * 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? { fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null if (!enabled) return null
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
?: return null ?: return null
if (candidateTag == dismissed) return null if (candidateTag == dismissed) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
return candidateTag.takeIf { isNewerVersion(it, currentVersion) } return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
} }
} }
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
import android.util.Log import android.util.Log
import com.m57.hermescontrol.ui.chat.extractTodosFromMap import com.m57.hermescontrol.ui.chat.extractTodosFromMap
import kotlinx.serialization.json.JsonObject
/** /**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances. * Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
@@ -38,8 +39,22 @@ object EventParser {
} }
// ── Notification / event (no id, has method) ───────────────────── // ── 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") @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) return parseParams(params, rawJson)
} }
@@ -95,7 +110,18 @@ object EventParser {
"message.complete" -> { "message.complete" -> {
val text = payload?.get("text") as? String ?: "" val text = payload?.get("text") as? String ?: ""
val reasoning = payload?.get("reasoning") 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" -> { "message.done" -> {
@@ -113,14 +139,16 @@ object EventParser {
} }
"tool.progress" -> { "tool.progress" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") as? String val name = payload?.get("name") as? String
val preview = payload?.get("preview") as? String val preview = payload?.get("preview") as? String
WsEvent.ToolProgress(name, preview, sessionId) WsEvent.ToolProgress(name, preview, sessionId, toolId)
} }
"tool.generating" -> { "tool.generating" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") 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" -> { "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.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@@ -106,21 +107,24 @@ object HermesWsClient {
/** /**
* Liveness thresholds aligned with reference clients (desktop/web): * 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 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 private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
// ── Internal state (all access through synchronized / atomic) ──────── // ── Internal state (all access through synchronized / atomic) ────────
private val requestId = AtomicInteger(0) private val requestId = AtomicInteger(0)
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
private val connectionGeneration = AtomicInteger(0) private val connectionGeneration = AtomicInteger(0)
private val connected = AtomicBoolean(false) private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false) private val intentionalClose = AtomicBoolean(false)
private val acceptQueuedMessages = AtomicBoolean(true) private val acceptQueuedMessages = AtomicBoolean(true)
private val appInForeground = AtomicBoolean(true) private val appInForeground = AtomicBoolean(true)
private val externalActivityConnectionLease = AtomicBoolean(false) private val externalActivityConnectionLease = AtomicBoolean(false)
private val backgroundConnectionLease = AtomicBoolean(false)
private val messageQueue = ConcurrentLinkedQueue<String>() private val messageQueue = ConcurrentLinkedQueue<String>()
private val queuedMessagesById = ConcurrentHashMap<String, String>() private val queuedMessagesById = ConcurrentHashMap<String, String>()
private val outboundLock = Any() private val outboundLock = Any()
@@ -351,6 +355,16 @@ object HermesWsClient {
wsScope.launch { wsScope.launch {
events.collect { event -> events.collect { event ->
if (event is WsEvent.GatewayReady) { 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 val epoch = event.data?.get("replay_epoch") as? String
if (!epoch.isNullOrEmpty()) { if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) { if (replayEpoch != null && replayEpoch != epoch) {
@@ -369,6 +383,7 @@ object HermesWsClient {
val isConnected: Boolean get() = connected.get() val isConnected: Boolean get() = connected.get()
private var foregroundProbeJob: Job? = null private var foregroundProbeJob: Job? = null
private var transportProbeJob: Job? = null
fun setAppForeground(foreground: Boolean) { fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground) 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() { fun acquireExternalActivityConnectionLease() {
externalActivityConnectionLease.set(true) externalActivityConnectionLease.set(true)
} }
@@ -407,10 +445,23 @@ object HermesWsClient {
disconnectIfIdleInBackground() disconnectIfIdleInBackground()
} }
fun acquireBackgroundConnectionLease() {
backgroundConnectionLease.set(true)
}
fun releaseBackgroundConnectionLease() {
backgroundConnectionLease.set(false)
disconnectIfIdleInBackground()
}
@VisibleForTesting
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
private fun disconnectIfIdleInBackground() { private fun disconnectIfIdleInBackground() {
synchronized(outboundLock) { synchronized(outboundLock) {
if (!appInForeground.get() && if (!appInForeground.get() &&
!externalActivityConnectionLease.get() && !externalActivityConnectionLease.get() &&
!backgroundConnectionLease.get() &&
!pendingReply && !pendingReply &&
pendingCalls.isEmpty() && pendingCalls.isEmpty() &&
messageQueue.isEmpty() messageQueue.isEmpty()
@@ -428,6 +479,11 @@ object HermesWsClient {
reconnectForNetworkChange(networkAvailable) reconnectForNetworkChange(networkAvailable)
} }
} }
wsScope.launch {
NetworkMonitor.transportChanges.collect {
probeLivenessOnTransportChange()
}
}
} }
var socketToCancel: WebSocket? = null var socketToCancel: WebSocket? = null
val shouldOpen = val shouldOpen =
@@ -517,7 +573,7 @@ object HermesWsClient {
* or because ticket refresh succeeded). Returns false if we are in gated mode * or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed. * and ticket refresh failed or cannot be performed.
*/ */
internal fun refreshWsTicketIfNeeded(generation: Int): Boolean { internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
val isGated = val isGated =
try { try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -533,11 +589,9 @@ object HermesWsClient {
// restart. Refresh it before each WebSocket handshake so automatic // restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token. // reconnect does not get stuck in AUTH_EXPIRED with a stale token.
val token = val token =
synchronized(DashboardSessionTokenRefresher) { runCatching {
runCatching { DashboardSessionTokenRefresher.refreshAsync()
DashboardSessionTokenRefresher.fetch(AuthManager.baseUrl(), OkHttpProvider.probe) }.getOrNull()
}.getOrNull()
}
synchronized(outboundLock) { synchronized(outboundLock) {
if (isCurrentGeneration(generation) && token != null) { if (isCurrentGeneration(generation) && token != null) {
runCatching { AuthManager.setToken(token) } runCatching { AuthManager.setToken(token) }
@@ -580,7 +634,7 @@ object HermesWsClient {
* ticket. Loopback mode: refresh the dashboard token and return it. * ticket. Loopback mode: refresh the dashboard token and return it.
* Returns null when a ticket could not be obtained. * Returns null when a ticket could not be obtained.
*/ */
internal fun mintWsTicket(): String? { internal suspend fun mintWsTicket(): String? {
val isGated = val isGated =
try { try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -588,7 +642,7 @@ object HermesWsClient {
false false
} }
if (!isGated) { if (!isGated) {
DashboardSessionTokenRefresher.refresh() DashboardSessionTokenRefresher.refreshAsync()
return AuthManager.getToken() return AuthManager.getToken()
} }
return requestWsTicket().ticket 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. */ /** 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 { try {
val client = OkHttpProvider.probe val client = OkHttpProvider.probe
val request = val request =
@@ -611,16 +665,12 @@ object HermesWsClient {
.post("{}".toRequestBody()) .post("{}".toRequestBody())
.build() .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) = val (code, body) =
kotlinx.coroutines.runBlocking(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (CookieManager.isInitialized()) { if (CookieManager.isInitialized()) {
CookieManager.useStore(CookieManager.cookieJar.currentServer()) CookieManager.useStore(CookieManager.cookieJar.currentServer())
} }
client.newCall(request).execute().use { resp -> client.newCall(request).await().use { resp ->
resp.code to resp.body.string() resp.code to resp.body.string()
} }
} }
@@ -682,7 +732,9 @@ object HermesWsClient {
webSocket?.close(1000, "Client closed") webSocket?.close(1000, "Client closed")
webSocket = null webSocket = null
closingSocket = null closingSocket = null
capabilityRequestIds.clear()
if (clearPendingMessages) { if (clearPendingMessages) {
backgroundConnectionLease.set(false)
messageQueue.clear() messageQueue.clear()
queuedMessagesById.clear() queuedMessagesById.clear()
pendingPromptSubmits.clear() pendingPromptSubmits.clear()
@@ -869,6 +921,13 @@ object HermesWsClient {
method: String, method: String,
params: Map<String, Any> = emptyMap(), params: Map<String, Any> = emptyMap(),
onSent: ((String) -> Unit)? = null, 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 { ): String {
val id = requestId.incrementAndGet().toString() val id = requestId.incrementAndGet().toString()
val decoratedParams = WsProfileParams.decorate(method, params) val decoratedParams = WsProfileParams.decorate(method, params)
@@ -888,13 +947,15 @@ object HermesWsClient {
pendingPromptSubmits.add(id) pendingPromptSubmits.add(id)
pendingReply = true pendingReply = true
} }
onSent?.invoke(id)
val ws = webSocket val ws = webSocket
if (ws != null && connected.get()) { if (ws != null && connected.get()) {
// Never replay send(true): the server may have executed it even if its response is lost. // 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 (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") Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
queueMessage(id, json) queueMessage(id, json)
recoverRejectedSocket(ws) recoverRejectedSocket(ws)
@@ -902,8 +963,9 @@ object HermesWsClient {
Log.w(TAG, "WS rejected oversized outgoing message — not retrying") Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
} }
} }
} else if (acceptQueuedMessages.get()) { } else if (acceptQueuedMessages.get() && queueIfDisconnected) {
if (isRetryableMessage(json)) { if (isRetryableMessage(json)) {
onSent?.invoke(id)
Log.d(TAG, "WS disconnected — queuing message") Log.d(TAG, "WS disconnected — queuing message")
queueMessage(id, json) queueMessage(id, json)
reconnect = true reconnect = true
@@ -1154,22 +1216,24 @@ object HermesWsClient {
if (intentionalClose.get()) return if (intentionalClose.get()) return
connectionGeneration.incrementAndGet() connectionGeneration.incrementAndGet()
} }
if (!refreshWsTicketIfNeeded(generation)) { wsScope.launch(Dispatchers.IO) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") if (!refreshWsTicketIfNeeded(generation)) {
return Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
} return@launch
if (!isCurrentGeneration(generation)) return }
val url = AuthManager.wsUrl() if (!isCurrentGeneration(generation)) return@launch
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") val url = AuthManager.wsUrl()
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") 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 request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) { synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) { if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket webSocket = newSocket
} else { } else {
newSocket.cancel() newSocket.cancel()
}
} }
} }
} }
@@ -1237,6 +1301,26 @@ object HermesWsClient {
) : WebSocketListener() { ) : WebSocketListener() {
private fun isCurrent(): Boolean = isCurrentGeneration(generation) 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( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
response: Response, response: Response,
@@ -1316,12 +1400,9 @@ object HermesWsClient {
} }
if (rpc.id == null) { if (rpc.id == null) {
@Suppress("UNCHECKED_CAST") // Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
val params = rpc.params?.toAny() as? Map<String, Any?> val sid = rpc.params?.eventSessionId()
val sid = val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
(params?.get("session_id") as? String)
?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String)
val seq = (params?.get("seq") as? Number)?.toInt()
if (!sid.isNullOrBlank() && seq != null) { if (!sid.isNullOrBlank() && seq != null) {
val prev = lastSeenSeq[sid] ?: 0 val prev = lastSeenSeq[sid] ?: 0
@@ -1360,6 +1441,7 @@ object HermesWsClient {
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
WsEvent.Unknown(text) WsEvent.Unknown(text)
} }
if (consumeCapabilityResponse(event)) return
when (event) { when (event) {
is WsEvent.RpcResult -> { is WsEvent.RpcResult -> {
synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) } synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) }
@@ -1426,6 +1508,7 @@ object HermesWsClient {
connected.set(false) connected.set(false)
ActiveSessionHolder.clear() ActiveSessionHolder.clear()
stopHealthTracking() stopHealthTracking()
capabilityRequestIds.clear()
if (isTerminalAuthClose(code, reason)) { if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
@@ -1454,6 +1537,7 @@ object HermesWsClient {
connected.set(false) connected.set(false)
ActiveSessionHolder.clear() ActiveSessionHolder.clear()
stopHealthTracking() stopHealthTracking()
capabilityRequestIds.clear()
val code = response?.code ?: 0 val code = response?.code ?: 0
if (code == 401 || code == 4401 || code == 4403 || if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true || 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? = fun JsonElement.toAny(): Any? =
when (this) { when (this) {
is JsonNull -> { is JsonNull -> {
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -163,86 +164,89 @@ class KanbanEventsClient {
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
val credential = HermesWsClient.mintWsTicket() scope.launch(Dispatchers.IO) {
if (credential.isNullOrBlank()) { val credential = HermesWsClient.mintWsTicket()
onStatus(KanbanLiveStatus.AUTH_FAILED) if (closed || gen != generation) return@launch
return if (credential.isNullOrBlank()) {
} scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return@launch
}
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url = val url =
AuthManager.endpointForBuild().webSocketUrl( AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH, path = KANBAN_EVENTS_PATH,
authParameter = authParam, authParameter = authParam,
credential = credential, credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board), extraParams = mapOf("since" to cursor.toString(), "board" to board),
) )
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED") val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
webSocket = webSocket =
httpClient.newWebSocket( httpClient.newWebSocket(
request, request,
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
response: Response, response: Response,
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) } 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
} }
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure( override fun onMessage(
webSocket: WebSocket, webSocket: WebSocket,
t: Throwable, text: String,
response: Response?, ) {
) { if (closed || gen != generation) return
if (closed || gen != generation) return val envelope = parseKanbanEventsFrame(text) ?: return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}") if (envelope.events.isEmpty()) return
scheduleReconnect(scope, gen, board, onEvents, onStatus) 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( 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, val storedSessionId: String? = null,
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */ /** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
val rawPayload: Map<String, Any?>? = null, val rawPayload: Map<String, Any?>? = null,
/** Stable completion identity for notification and read-tracking correlation. */
val completionId: String =
java.util.UUID
.randomUUID()
.toString(),
) : WsEvent() ) : WsEvent()
data class MessageDone( data class MessageDone(
@@ -104,23 +109,25 @@ sealed class WsEvent {
* Live tool execution progress with optional preview content. * Live tool execution progress with optional preview content.
* *
* Events: `tool.progress` * Events: `tool.progress`
* Payload: `{ name?: string, preview?: string }` * Payload: `{ tool_id?: string, name?: string, preview?: string }`
*/ */
data class ToolProgress( data class ToolProgress(
val name: String? = null, val name: String? = null,
val preview: String? = null, val preview: String? = null,
val sessionId: String? = null, val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent() ) : WsEvent()
/** /**
* Tool generation active state. * Tool generation active state.
* *
* Events: `tool.generating` * Events: `tool.generating`
* Payload: `{ name?: string }` * Payload: `{ tool_id?: string, name?: string }`
*/ */
data class ToolGenerating( data class ToolGenerating(
val name: String? = null, val name: String? = null,
val sessionId: String? = null, val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent() ) : WsEvent()
/** /**
@@ -28,6 +28,7 @@ object WsMethods {
const val SESSION_USAGE = "session.usage" const val SESSION_USAGE = "session.usage"
const val PROFILES_CONFIGURE = "profiles.configure" const val PROFILES_CONFIGURE = "profiles.configure"
const val PROFILES_LIST = "profiles.list" const val PROFILES_LIST = "profiles.list"
const val MODEL_OPTIONS = "model.options"
// ── Projects ────────────────────────────────────────────────────────── // ── Projects ──────────────────────────────────────────────────────────
@@ -65,6 +66,7 @@ object WsMethods {
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */ /** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
const val GATEWAY_PING = "gateway.ping" const val GATEWAY_PING = "gateway.ping"
const val CLIENT_CAPABILITIES = "client.capabilities"
// ── Commands catalog ────────────────────────────────────────────────── // ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog" const val COMMANDS_CATALOG = "commands.catalog"
@@ -154,6 +156,7 @@ object WsMethods {
// Evidence: methods_session.py lines 42, 164, 232, 317, 845 // Evidence: methods_session.py lines 42, 164, 232, 317, 845
SESSION_CREATE, // session.create SESSION_CREATE, // session.create
SESSION_LIST, // session.list SESSION_LIST, // session.list
MODEL_OPTIONS, // model.options: @_profile_scoped
SESSION_RESUME, // session.resume SESSION_RESUME, // session.resume
SESSION_DELETE, // session.delete SESSION_DELETE, // session.delete
SESSION_STATUS, // session.status 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 androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R 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.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
@@ -43,16 +43,16 @@ import java.util.concurrent.atomic.AtomicBoolean
* message notifications, which is correct. * message notifications, which is correct.
* *
* Lifecycle: * Lifecycle:
* - Started by [NotificationHelper.start] when the app goes to the * - Started by [NotificationHelper.start] when the app goes to the background
* background while a reply is still pending (the user sent a message or * either while a reply is still pending (issue #794) or when persistent background
* replied from a notification and the agent has not finished yet). * connection ([com.m57.hermescontrol.data.local.AuthManager.isKeepConnectedInBackground])
* [NotificationHelper.start] is a no-op when nothing is pending, so the * is enabled.
* service — and its mandatory persistent notification — only exists * - In persistent keep-connected mode, the service stays alive across idle states
* while the user is actually waiting for a reply (issue #794). * and updates its ongoing notification truthfully ([BackgroundNotificationState]).
* - Stopped by [NotificationHelper.stop] when the app returns to the * - In replies-only mode (default), the service retires itself once the pending reply
* foreground (called from MainActivity.onStart), or by the * completes in the background.
* service itself once the pending reply completes in the background * - Stopped by [NotificationHelper.stop] when the app returns to the foreground, or
* (the reply notification replaces the persistent "waiting" one). * retired automatically upon auth expiry or terminal disconnection.
* *
* The service collects [WsEvent]s from [HermesWsClient] — the same stream * The service collects [WsEvent]s from [HermesWsClient] — the same stream
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete] * the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
@@ -68,20 +68,36 @@ class ChatNotificationService : Service() {
private val isAppInForeground = AtomicBoolean(false) private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle() 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) { fun setAppForeground(foreground: Boolean) {
isAppInForeground.set(foreground) isAppInForeground.set(foreground)
} }
fun isAppInForeground(): Boolean = isAppInForeground.get() fun isAppInForeground(): Boolean = isAppInForeground.get()
fun updateForegroundNotification(state: BackgroundNotificationState) {
activeServiceInstance?.updateNotification(state)
}
} }
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var eventCollector: Job? = null private var eventCollector: Job? = null
private var statusCollector: Job? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
activeServiceInstance = this
createNotificationChannels() createNotificationChannels()
startEventCollection() startEventCollection()
startStatusCollection()
} }
private fun startEventCollection() { private fun startEventCollection() {
@@ -100,10 +116,23 @@ class ChatNotificationService : Service() {
.take(100) .take(100)
.replace("\n", " ") .replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) } .ifBlank { getString(R.string.notif_new_message) }
showReplyNotification( val targetSessionId =
preview,
event.storedSessionId 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 // The wait is over — retire the foreground
// service. The reply notification above // service. The reply notification above
@@ -113,7 +142,7 @@ class ChatNotificationService : Service() {
// service is not restarted on the next // service is not restarted on the next
// ON_STOP (issue #794). // ON_STOP (issue #794).
// A delayed completion must not retire a newer turn/start. // A delayed completion must not retire a newer turn/start.
if (!HermesWsClient.pendingReply) lifecycle.complete(generation) BackgroundConnectionController.default.onReplyCompleted(generation)
} }
is WsEvent.ClarifyRequest -> { 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( private fun showReplyNotification(
text: String, text: String,
sessionId: String?, sessionId: String?,
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
) { ) {
val builder = val builder =
NotificationCompat NotificationCompat
@@ -172,6 +229,39 @@ class ChatNotificationService : Service() {
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(buildContentIntent(sessionId)) .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()) { if (!sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder) val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput = val remoteInput =
@@ -183,6 +273,11 @@ class ChatNotificationService : Service() {
val replyIntent = val replyIntent =
Intent(this, NotificationReplyReceiver::class.java).apply { Intent(this, NotificationReplyReceiver::class.java).apply {
action = "$packageName.ACTION_NOTIFICATION_REPLY" action = "$packageName.ACTION_NOTIFICATION_REPLY"
component =
android.content.ComponentName(
this@ChatNotificationService,
NotificationReplyReceiver::class.java,
)
setPackage(packageName) setPackage(packageName)
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId) putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
} }
@@ -207,19 +302,32 @@ class ChatNotificationService : Service() {
builder.addAction(action) builder.addAction(action)
} }
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notification = builder.build()
manager.notify(PENDING_NOTIFICATION_ID, 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 { private fun buildContentIntent(sessionId: String?): PendingIntent {
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
action = MainActivity.ACTION_OPEN_CHAT_FROM_NOTIFICATION 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 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( return PendingIntent.getActivity(
this, this,
sessionId?.hashCode() ?: 0, sessionId?.hashCode() ?: 0,
@@ -269,10 +377,13 @@ class ChatNotificationService : Service() {
flags: Int, flags: Int,
startId: Int, startId: Int,
): Int { ): Int {
val initialDecision =
BackgroundConnectionPolicy.evaluate(BackgroundConnectionController.defaultSnapshot(isDeparting = true))
val initialText = resolveNotificationText(initialDecision.notificationState)
lifecycle.onStart( lifecycle.onStart(
owner = this, owner = this,
promote = { 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: // Do not remove foreground status before Android accepts retirement:
// a newer start may already be queued, but not delivered to us yet. // 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 onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { override fun onDestroy() {
if (activeServiceInstance === this) {
activeServiceInstance = null
}
lifecycle.onDestroyed(this) lifecycle.onDestroyed(this)
eventCollector?.cancel() eventCollector?.cancel()
statusCollector?.cancel()
serviceScope.cancel() serviceScope.cancel()
super.onDestroy() 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 { object NotificationHelper {
fun start(context: Context) { 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) val intent = Intent(context, ChatNotificationService::class.java)
ChatNotificationService.lifecycle.start { BackgroundConnectionController.default.onAppPause {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent) context.startForegroundService(intent)
} else { } else {
@@ -313,9 +459,7 @@ object NotificationHelper {
} }
fun stop(context: Context) { fun stop(context: Context) {
// Rotation can resume the activity before the queued service is created. BackgroundConnectionController.default.onAppResume()
// Direct stopService here crashes Android while foreground promotion is owed.
ChatNotificationService.lifecycle.stop()
} }
fun setAppForeground( fun setAppForeground(
@@ -324,6 +468,9 @@ object NotificationHelper {
) { ) {
ChatNotificationService.setAppForeground(foreground) ChatNotificationService.setAppForeground(foreground)
HermesWsClient.setAppForeground(foreground) HermesWsClient.setAppForeground(foreground)
if (!foreground) {
BackgroundConnectionController.default.reconcileState()
}
} }
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground() fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
@@ -24,6 +24,13 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id" const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L 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 // Reusable scope for async reply processing — avoids creating a new
@@ -50,7 +57,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
.setContentText("Replied") .setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.build() .addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
},
).build()
override fun onReceive( override fun onReceive(
context: Context, context: Context,
@@ -78,6 +89,17 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
return@withContext 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 = val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId) ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId) ?: resumeSession(sessionId)
@@ -102,8 +124,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
dao.upsert(entity) dao.upsert(entity)
val repliedNotification = buildReplyNotification(context) val repliedNotification = buildReplyNotification(context)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)
// The follow-up turn is now pending — keep the // The follow-up turn is now pending — keep the
// foreground service alive if it retired after 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R 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.Achievement
import com.m57.hermescontrol.data.model.RecentUnlock import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -72,6 +73,7 @@ fun AchievementsScreen(
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() }, viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showResetDialog 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.loadAchievements()
viewModel.loadRecentUnlocks() viewModel.loadRecentUnlocks()
} }
@@ -129,7 +132,10 @@ fun AchievementsScreen(
title = { Text(stringResource(R.string.screen_achievements)) }, title = { Text(stringResource(R.string.screen_achievements)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() }, onRefresh = {
viewModel.loadAchievements(forceRefresh = true)
viewModel.loadRecentUnlocks()
},
modifier = modifier, modifier = modifier,
actions = { actions = {
Box { Box {
@@ -1,12 +1,16 @@
package com.m57.hermescontrol.ui.achievements package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel 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.Achievement
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.RecentUnlock import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -46,9 +50,53 @@ class AchievementsViewModel :
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow() val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
private val api get() = ApiClient.hermesApi private val api get() = ApiClient.hermesApi
private val achievementsCache = SwrCache<String, AchievementsResponse>()
fun loadAchievements() { fun clearScopeOwnedState() {
safeLaunchLoad( _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() } }, apiCall = { safeApiCall { api.getAchievements() } },
onStart = { onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -100,6 +148,7 @@ class AchievementsViewModel :
} }
fun rescan() { fun rescan() {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { api.rescanAchievements() } }, apiCall = { safeApiCall { api.rescanAchievements() } },
onStart = { onStart = {
@@ -111,6 +160,12 @@ class AchievementsViewModel :
?.mapNotNull { it.category } ?.mapNotNull { it.category }
?.distinct() ?.distinct()
?.sorted() ?: emptyList() ?.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 { _uiState.update {
it.copy( it.copy(
isRescanning = false, isRescanning = false,
@@ -155,7 +210,7 @@ class AchievementsViewModel :
toastMessage = "Achievement state reset", toastMessage = "Achievement state reset",
) )
} }
loadAchievements() loadAchievements(forceRefresh = true)
}, },
onError = { errorMsg -> onError = { errorMsg ->
_uiState.update { _uiState.update {
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -235,68 +237,72 @@ class BotsViewModel(
} }
} }
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars). coroutineScope {
var profilesWithMeta: List<ProfileInfo>? = null val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() // First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
val jsonElement = var profilesWithMeta: List<ProfileInfo>? = null
when (rpcResult) { try {
is JsonElement -> rpcResult val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
null -> null val jsonElement =
else -> rpcResult.toJsonElement() 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) { } catch (_: Exception) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement) // Fallback to REST API below
if (!resp.profiles.isNullOrEmpty()) { }
profilesWithMeta = resp.profiles
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
} }
} val activeResult = activeDeferred.await()
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) { 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 { } else {
safeApiCall { ApiClient.hermesApi.getProfiles() } val err =
} (profilesResult as? NetworkResult.Failure)?.error?.message
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() } ?: "Failed to load bots"
_uiState.update {
if (profilesWithMeta != null) { it.copy(
val activeName = (activeResult as? NetworkResult.Success)?.data?.active isLoading = false,
_uiState.update { isRefreshing = false,
it.copy( errorMessage = err,
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,
)
} }
} }
} }
@@ -696,6 +696,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred } storedId?.let { inFlightTurns[it] = turnDeferred }
try { 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 HermesWsClient
.request( .request(
WsMethods.PROMPT_SUBMIT, WsMethods.PROMPT_SUBMIT,
@@ -593,16 +593,20 @@ internal fun InlineAttachment(
val isSaving = savingPath != null && savingPath == attachmentPath val isSaving = savingPath != null && savingPath == attachmentPath
val isOpening = openingPath != null && openingPath == attachmentPath val isOpening = openingPath != null && openingPath == attachmentPath
val clickable = Modifier.clickable { onOpen(attachment) } val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isVideo) { if (attachment.isVideo || attachment.isAudio) {
var showVideoDialog by remember { mutableStateOf(false) } var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer( com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
videoUri = attachment.uri, uri = attachment.gatewayUrl ?: attachment.uri,
onFullScreenClick = { showVideoDialog = true }, title = attachment.name,
mimeType = attachment.mimeType,
onFullScreenClick = { showMediaDialog = true },
) )
if (showVideoDialog) { if (showMediaDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog( com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
videoUri = attachment.uri, mediaUri = attachment.gatewayUrl ?: attachment.uri,
onDismissRequest = { showVideoDialog = false }, title = attachment.name,
mimeType = attachment.mimeType,
onDismissRequest = { showMediaDialog = false },
) )
} }
} else if (attachment.isImage) { } else if (attachment.isImage) {
@@ -24,7 +24,8 @@ object ChatInputPolicy {
text: String, text: String,
pendingAttachments: List<Any>, pendingAttachments: List<Any>,
isConnected: Boolean, 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 * Whether the input placeholder should read "queued" rather than the plain
@@ -85,6 +85,8 @@ data class ChatMessage(
val tokenCount: Int? = null, val tokenCount: Int? = null,
/** Tokens per second generation speed (persisted to SQLite). */ /** Tokens per second generation speed (persisted to SQLite). */
val tps: Double? = null, 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. * UI state management and WebSocket event handling.
*/ */
open class ChatPersistenceRepository( 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. */ /** Persist a single message for the given session. */
suspend fun persistMessage( suspend fun persistMessage(
message: ChatMessage, message: ChatMessage,
sessionId: String, sessionId: String,
) { ) {
dao.upsert(message.toEntity(sessionId)) daoProvider().upsert(message.toEntity(sessionId))
} }
/** Persist multiple messages in one transaction. */ /** Persist multiple messages in one transaction. */
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
sessionId: String, sessionId: String,
) { ) {
val entities = messages.map { it.toEntity(sessionId) } val entities = messages.map { it.toEntity(sessionId) }
dao.upsertAll(entities) daoProvider().upsertAll(entities)
} }
/** Load cached messages for a session from Room. */ /** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> = 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). */ /** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) { suspend fun clearMessagesForSession(sessionId: String) {
dao.deleteMessagesForSession(sessionId) daoProvider().deleteMessagesForSession(sessionId)
} }
} }
@@ -155,6 +155,7 @@ fun ChatScreen(
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that // Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles). // read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState val searchState = viewModel.searchState
@@ -426,6 +427,20 @@ fun ChatScreen(
context = context, 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 // Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects( ChatLifecycleEffects(
sessionId = sessionId, sessionId = sessionId,
@@ -442,6 +457,7 @@ fun ChatScreen(
scrollController = scrollController, scrollController = scrollController,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
viewModel = viewModel, viewModel = viewModel,
isOverlayActive = !isChatContentReadable,
) )
HermesScaffold( HermesScaffold(
@@ -503,22 +519,6 @@ fun ChatScreen(
} }
}, },
actions = { 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() }) { IconButton(onClick = { viewModel.createNewSession() }) {
Icon( Icon(
imageVector = Icons.Filled.Add, imageVector = Icons.Filled.Add,
@@ -542,6 +542,33 @@ fun ChatScreen(
expanded = showSessionMenu, expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false }, 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( DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) }, text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = { leadingIcon = {
@@ -748,15 +775,18 @@ fun ChatScreen(
inputFieldValue = inputFieldValue, inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it }, onInputChange = { inputFieldValue = it },
onSend = { onSend = {
viewModel.sendMessage(inputFieldValue.text) if (viewModel.sendMessage(inputFieldValue.text)) {
inputFieldValue = TextFieldValue("") inputFieldValue = TextFieldValue("")
// Jump to bottom after send (serialized through the controller). // Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true) scrollController.jumpToBottom(animated = true)
}
}, },
onMicTap = mediaLaunchers.onMicTap, onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening, isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected, isConnected = state.isConnected,
isSessionReady = state.isSessionReady,
sessionPreparationFailed = state.resumeError != null,
commandCatalog = state.commandCatalog, commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts, slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments, pendingAttachments = state.pendingAttachments,
@@ -776,6 +806,7 @@ fun ChatScreen(
}, },
// Composer toolbar wiring (PR 1) // Composer toolbar wiring (PR 1)
currentSessionModel = state.currentSessionModel, currentSessionModel = state.currentSessionModel,
showModelProvider = state.showModelProvider,
reasoningLevel = state.reasoningLevel, reasoningLevel = state.reasoningLevel,
onModelTap = { viewModel.openModelPicker() }, onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, 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.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.notification.ReplyNotificationTracker
/** /**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
@@ -20,12 +21,93 @@ internal fun mapServerMessages(
offset: Int, offset: Int,
latestPaging: Boolean, latestPaging: Boolean,
liveMessages: List<ChatMessage>, liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
context: android.content.Context? = null,
): List<ChatMessage> { ): List<ChatMessage> {
val existingReasoningMap = val existingReasoningMap =
liveMessages liveMessages
.filter { it.reasoningText.isNotBlank() } .filter { it.reasoningText.isNotBlank() }
.associateBy { it.content } .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 // 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 // stream was the only source of `toolName`. Match each REST tool row
// to its WS counterpart by RESULT CONTENT (not position — pagination // 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 } val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
mapped.add( mapped.add(
ChatMessage( ChatMessage(
@@ -184,6 +272,7 @@ internal fun mapServerMessages(
isStreaming = false, isStreaming = false,
displayKind = msg.display_kind, displayKind = msg.display_kind,
tokenCount = tokenCount, 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.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement 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 com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -118,6 +120,7 @@ data class ChatUiState(
val showUserMessageTokens: Boolean = true, val showUserMessageTokens: Boolean = true,
val showAssistantMessageTokens: Boolean = true, val showAssistantMessageTokens: Boolean = true,
val showTokensPerSecond: Boolean = true, val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
// Commands catalog // Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(), val commandCatalog: CommandCatalog = CommandCatalog(),
// Per-command usage counts for the slash-autocomplete ranking (issue // Per-command usage counts for the slash-autocomplete ranking (issue
@@ -181,6 +184,7 @@ data class ChatUiState(
/** Agent todo / plan items (issue #736). */ /** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(), val todos: List<TodoItem> = emptyList(),
// Session resume recovery (desktop parity: bounded auto-retry + error UI) // Session resume recovery (desktop parity: bounded auto-retry + error UI)
val isSessionReady: Boolean = false,
val resumeError: String? = null, val resumeError: String? = null,
val isResumeRetrying: Boolean = false, val isResumeRetrying: Boolean = false,
/** Text staged to prefill the composer (e.g. from /undo). */ /** Text staged to prefill the composer (e.g. from /undo). */
@@ -322,9 +326,9 @@ class ChatViewModel(
application: Application, application: Application,
private val startCleanup: Boolean, private val startCleanup: Boolean,
repo: ChatPersistenceRepository = repo: ChatPersistenceRepository =
ChatPersistenceRepository( ChatPersistenceRepository {
HermesDatabase.get(application).chatMessageDao(), HermesDatabase.get(application).chatMessageDao()
), },
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext), slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, 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"; // fire with a stale id and 4001 "session not found";
// handleGatewayReady rebinds it on the re-resume. // handleGatewayReady rebinds it on the re-resume.
runtimeSessionId = null 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 // Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526). // across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending() wsClient.rejectAllPending()
@@ -766,43 +777,7 @@ class ChatViewModel(
_streamingState.update { result.streamingState } _streamingState.update { result.streamingState }
// Process side-effects from the reducer // Process side-effects from the reducer
for (effect in result.effects) { dispatchReducerEffects(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)
}
}
}
}
// Handle complex events that need ViewModel-specific context // Handle complex events that need ViewModel-specific context
when (event) { when (event) {
@@ -811,84 +786,7 @@ class ChatViewModel(
} }
is WsEvent.SessionInfo -> { is WsEvent.SessionInfo -> {
// Session info pushed by backend when config changes handleSessionInfo(event.data)
// (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,
)
}
}
} }
is WsEvent.MessageToken -> { is WsEvent.MessageToken -> {
@@ -1231,6 +1129,126 @@ class ChatViewModel(
return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId 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 ──────────────────────────────────────────── // ── RPC response handling ────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -1243,8 +1261,14 @@ class ChatViewModel(
if (request != null && isStaleSessionRequest(request)) return if (request != null && isStaleSessionRequest(request)) return
when (method) { when (method) {
WsMethods.SESSION_CREATE -> { WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return val resultMap = result as? Map<String, Any?>
val runtimeId = resultMap["session_id"] as? String ?: return 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 val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId runtimeSessionId = runtimeId
// The gateway persists the row lazily on the first prompt — // The gateway persists the row lazily on the first prompt —
@@ -1254,6 +1278,7 @@ class ChatViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
currentSessionId = storageId, currentSessionId = storageId,
isSessionReady = true,
isLoading = false, isLoading = false,
messages = if (pendingInitialPrompt != null) it.messages else emptyList(), messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
chatTitle = "Hermes", chatTitle = "Hermes",
@@ -1301,7 +1326,7 @@ class ChatViewModel(
// must stay the storage key — storing the runtime id here made // must stay the storage key — storing the runtime id here made
// every later resume 4007 "session not found" (the DB lookup // every later resume 4007 "session not found" (the DB lookup
// misses) and the REST transcript 404. // 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 storageId = resultMap["stored_session_id"] as? String ?: runtimeId
val generation = val generation =
resetSessionState( resetSessionState(
@@ -1310,6 +1335,7 @@ class ChatViewModel(
isLoading = false, isLoading = false,
) )
runtimeSessionId = runtimeId runtimeSessionId = runtimeId
resumedGeneration = generation
ActiveSessionHolder.set(runtimeId, storageId) ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false sessionGoneRecoveryInFlight = false
@@ -1341,16 +1367,22 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> { WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?> 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. // Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true sessionHasServerPresence = true
val sessionId = val sessionId =
request?.sessionId request?.sessionId
?: (resultMap?.get("resumed") as? String) ?: (resultMap["resumed"] as? String)
?: _uiState.value.currentSessionId ?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort // 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 model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String val reasoningEffort = infoMap?.get("reasoning_effort") as? String
@@ -1416,7 +1448,7 @@ class ChatViewModel(
// Reconnect replay: resume payload can carry `pending_approval` // Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for // (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked. // 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) { if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval( approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval, pendingApproval,
@@ -1575,6 +1607,7 @@ class ChatViewModel(
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Error ($method): $errorMsg", 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 * 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 * 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
*/ */
fun sendMessage(text: String) { fun sendMessage(text: String): Boolean {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return if (!canSubmitMessage()) return false
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
val trimmed = text.trim() val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) { if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -1605,10 +1639,10 @@ class ChatViewModel(
// of requiring the user to hand-type the provider/model. // of requiring the user to hand-type the provider/model.
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) { if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
openModelPicker() openModelPicker()
return return true
} }
handleSlashCommand(trimmed) handleSlashCommand(trimmed)
return return true
} }
val oversizedAttachment = val oversizedAttachment =
@@ -1622,7 +1656,7 @@ class ChatViewModel(
composerTextToRestore = text, composerTextToRestore = text,
) )
} }
return return true
} }
// Snapshot + clear attachments so the input bar empties immediately // 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 // Issue #969: Session creation is still in-flight. Hold the prompt
// so it is dispatched automatically the moment SESSION_CREATE lands. // so it is dispatched automatically the moment SESSION_CREATE lands.
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage) pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
return return true
} }
dispatchPrompt( dispatchPrompt(
@@ -1665,8 +1699,23 @@ class ChatViewModel(
agentSessionId = agentSessionId, agentSessionId = agentSessionId,
userMessage = userMessage, 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( private fun dispatchPrompt(
text: String, text: String,
attachments: List<Attachment>, attachments: List<Attachment>,
@@ -1832,6 +1881,15 @@ class ChatViewModel(
}, },
) )
} else { } 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( wsClient.sendMessage(
agentSessionId, agentSessionId,
fullText, 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() { private fun captureTurnUsageBaselineIfNeeded() {
if (_streamingState.value.turnUsageBaselineCaptured) return if (_streamingState.value.turnUsageBaselineCaptured) return
_streamingState.update { _streamingState.update {
@@ -2307,8 +2376,15 @@ class ChatViewModel(
) { ) {
if (text.isBlank()) return if (text.isBlank()) return
val sessionId = runtimeSessionId ?: return val sessionId = runtimeSessionId ?: return
val storageSessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(isAgentTyping = true) } _uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(ioDispatcher) { 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( wsClient.sendMessage(
sessionId, sessionId,
text, text,
@@ -2559,6 +2635,7 @@ class ChatViewModel(
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(), showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(), showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(),
showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(), showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(),
showModelProvider = AuthManager.isModelProviderShown(),
) )
} }
} }
@@ -2730,6 +2807,7 @@ class ChatViewModel(
serverOffset, serverOffset,
latestPaging, latestPaging,
_uiState.value.messages, _uiState.value.messages,
context = getApplication(),
) )
loadedMessageOffset = serverOffset loadedMessageOffset = serverOffset
withContext(ioDispatcher) { withContext(ioDispatcher) {
@@ -2860,6 +2938,7 @@ class ChatViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
messages = emptyList(), messages = emptyList(),
isSessionReady = false,
currentSessionId = sessionId, currentSessionId = sessionId,
chatTitle = title, chatTitle = title,
isAgentTyping = false, isAgentTyping = false,
@@ -2906,6 +2985,9 @@ class ChatViewModel(
sessionId: String, sessionId: String,
generation: Long, generation: Long,
) { ) {
resumedGeneration = -1L
hydratedGeneration = -1L
_uiState.update { it.copy(isSessionReady = false) }
val requestSequence = ++resumeRequestSequence val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value val profile = AuthManager.activeProfileId.value
@@ -2956,6 +3038,7 @@ class ChatViewModel(
isResumeRetrying = false, isResumeRetrying = false,
resumeError = null, resumeError = null,
errorMessage = null, errorMessage = null,
isSessionReady = runtimeSessionId != null,
) )
} }
} }
@@ -3030,7 +3113,7 @@ class ChatViewModel(
) { ) {
// Only handle if still on this session. // Only handle if still on this session.
if (!isCurrentSessionRequest(sessionId, generation)) return 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. // New session → reset the counter for a fresh backoff cycle.
if (resumeRetrySessionId != sessionId) { if (resumeRetrySessionId != sessionId) {
@@ -3101,7 +3184,11 @@ class ChatViewModel(
* resumeSession: reconnect / reselect / Retry all reset the counter). * resumeSession: reconnect / reselect / Retry all reset the counter).
*/ */
fun retryResumeSession() { fun retryResumeSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId
if (sessionId == null) {
createNewSession()
return
}
val generation = sessionGeneration val generation = sessionGeneration
cancelResumeRetry() cancelResumeRetry()
_uiState.update { _uiState.update {
@@ -3154,6 +3241,7 @@ class ChatViewModel(
returnedOffset, returnedOffset,
latestPaging, latestPaging,
_uiState.value.messages, _uiState.value.messages,
isPagingOlder = true,
) )
loadedMessageOffset = returnedOffset loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) } withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
@@ -3238,6 +3326,7 @@ class ChatViewModel(
nextOffset, nextOffset,
latestPaging, latestPaging,
_uiState.value.messages, _uiState.value.messages,
isPagingOlder = true,
) )
if (incoming.isEmpty()) return@launch if (incoming.isEmpty()) return@launch
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) } withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
@@ -429,9 +429,38 @@ object ChatWsEventReducer {
// above before this return because the final snapshot is still needed // above before this return because the final snapshot is still needed
// as the next turn's cumulative baseline. // as the next turn's cumulative baseline.
if (text.isBlank()) { 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( return ReducerResult(
state = usageState.copy(isAgentTyping = false), state = usageState.copy(messages = updatedMessages, isAgentTyping = false),
streamingState = StreamingState(), streamingState = StreamingState(),
effects = effects,
) )
} }
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull() val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
@@ -449,6 +478,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(), finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, tps = tps,
completionId = event.completionId,
) ?: ChatMessage( ) ?: ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
content = text, content = text,
@@ -456,6 +486,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(), finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, tps = tps,
completionId = event.completionId,
) )
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
@@ -639,12 +670,7 @@ object ChatWsEventReducer {
OkHttpProvider.json.encodeToString(it.toJsonElement()) OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: "" } ?: ""
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
val toolIdx = val toolIdx = findToolIndex(messages, event.name, event.data?.get("tool_id") as? String, ToolStatus.RUNNING)
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
val updated = val updated =
@@ -675,10 +701,10 @@ object ChatWsEventReducer {
/** /**
* Attaches [ToolOutputRiskData] to the matching tool message. * Attaches [ToolOutputRiskData] to the matching tool message.
* *
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name]. * Finds the matching stable tool ID first. If the event has no ID or no ID
* If no RUNNING message exists (tool already completed), falls back to the last * match exists, it falls back to the tool name for legacy payloads. It
* COMPLETED tool with that name. This covers the case where the risk event arrives * prefers RUNNING tools and then falls back to COMPLETED tools because the
* after tool.complete. * risk event may arrive after tool.complete.
*/ */
private fun onToolOutputRisk( private fun onToolOutputRisk(
state: ChatUiState, state: ChatUiState,
@@ -694,20 +720,11 @@ object ChatWsEventReducer {
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
// Prefer RUNNING tool, fall back to COMPLETED // Prefer RUNNING tool, fall back to COMPLETED. Stable tool IDs win over
var toolIdx = // name matching so overlapping calls to the same tool stay isolated.
messages.indexOfLast { var toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) { if (toolIdx < 0) {
toolIdx = toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.COMPLETED)
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.COMPLETED
}
} }
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
@@ -904,12 +921,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolProgress, event: WsEvent.ToolProgress,
): ReducerResult { ): ReducerResult {
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
val toolIdx = val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "") messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
@@ -925,12 +937,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolGenerating, event: WsEvent.ToolGenerating,
): ReducerResult { ): ReducerResult {
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
val toolIdx = val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "") 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( private fun onSubagentEvent(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState, 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 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 * `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. * 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 { enum class MediaKind {
IMAGE, IMAGE,
@@ -20,6 +18,8 @@ enum class MediaKind {
private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> = private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
mapOf( mapOf(
"3gp" to (MediaKind.VIDEO to "video/3gpp"),
"aac" to (MediaKind.AUDIO to "audio/aac"),
"avi" to (MediaKind.VIDEO to "video/x-msvideo"), "avi" to (MediaKind.VIDEO to "video/x-msvideo"),
"bmp" to (MediaKind.IMAGE to "image/bmp"), "bmp" to (MediaKind.IMAGE to "image/bmp"),
"csv" to (MediaKind.FILE to "text/csv"), "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"), "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 { * Extracts the file extension from a clean path or URL, ignoring query parameters and fragments.
val ext = * Never searches queries for extensions to avoid false positives.
path */
.split('?', limit = 2)[0] fun extractFileExtension(pathOrUrl: String): String {
.split('.') val clean =
.lastOrNull() 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() ?.lowercase()
.orEmpty() .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. */ /** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */
fun mediaMimeForPath(path: String): String { fun mediaMimeForPath(path: String): String {
val ext = val ext = extractExtensionFromMediaSource(path)
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
?.lowercase()
.orEmpty()
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream" 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? = fun gatewayPathFromUrl(url: String): String? =
Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let { Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let {
runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it) 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 * 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 * `/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 { fun mediaNameFromPath(path: String): String {
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it } gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
val cleanPath = path.split('?', limit = 2)[0] val cleanPath = path.substringBefore('#').substringBefore('?')
return runCatching { return runCatching {
java.net java.net
.URI(cleanPath) .URI(cleanPath)
.path .path
.split('/') ?.split('/')
.lastOrNull { it.isNotBlank() } ?.lastOrNull { it.isNotBlank() }
}.getOrNull() }.getOrNull()
?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() } ?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() }
?: path ?: path
@@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build 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.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@@ -65,13 +69,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.m57.hermescontrol.R 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.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.DiffViewCard 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.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* Composes the collapsed summary lines for a tool card. * Composes the collapsed summary lines for a tool card.
@@ -237,34 +249,7 @@ internal fun ToolBubble(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
if (showRawJson) { if (showRawJson) {
// Raw JSON view — selectable + copy button ToolRawJsonView(rawContent = message.content)
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),
)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@@ -310,34 +295,8 @@ internal fun ToolBubble(
.clickable(role = Role.Button) { showRawJson = true }, .clickable(role = Role.Button) { showRawJson = true },
) )
} else { } else {
// Unparseable content — show raw JSON, selectable // Unparseable content — show raw JSON
Box { ToolRawJsonView(rawContent = message.content)
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),
)
}
} }
} }
@@ -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 @Composable
private fun CopyButton( private fun CopyButton(
visible: Boolean, visible: Boolean,
@@ -80,6 +80,8 @@ fun ChatInputBar(
isAgentTyping: Boolean, isAgentTyping: Boolean,
isConnected: Boolean, isConnected: Boolean,
commandCatalog: CommandCatalog, commandCatalog: CommandCatalog,
isSessionReady: Boolean,
sessionPreparationFailed: Boolean = false,
slashUsageCounts: Map<String, Int> = emptyMap(), slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(), pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {}, onCameraTap: () -> Unit = {},
@@ -98,12 +100,13 @@ fun ChatInputBar(
fastSupported: Boolean = false, fastSupported: Boolean = false,
isFastModeChanging: Boolean = false, isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {}, onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
) { ) {
// Allow sending while the agent is mid-turn or awaiting approval: the // 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 // 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 // (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too. // 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 // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } 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 ── // ── TOP ROW: Borderless input field ──
Row( Row(
modifier = modifier =
@@ -296,6 +315,7 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel, reasoningLevel = reasoningLevel,
isListening = isListening, isListening = isListening,
canSend = canSend, canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
onSend = onSend, onSend = onSend,
onAttachTap = { showAttachmentTray = !showAttachmentTray }, onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap, onModelTap = onModelTap,
@@ -308,6 +328,7 @@ fun ChatInputBar(
fastSupported = fastSupported, fastSupported = fastSupported,
isFastModeChanging = isFastModeChanging, isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode, onToggleFastMode = onToggleFastMode,
showModelProvider = showModelProvider,
) )
AttachmentTray( AttachmentTray(
@@ -10,18 +10,24 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.data.ws.ConnectionStatus 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.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable @Composable
fun ChatLifecycleEffects( fun ChatLifecycleEffects(
@@ -39,6 +45,7 @@ fun ChatLifecycleEffects(
scrollController: ChatScrollController, scrollController: ChatScrollController,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel, viewModel: ChatViewModel,
isOverlayActive: Boolean = false,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -87,6 +94,32 @@ fun ChatLifecycleEffects(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } 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+ // Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission = val requestNotificationPermission =
rememberLauncherForActivityResult( 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, onMicTap: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canSend: Boolean = false, canSend: Boolean = false,
showSend: Boolean = canSend,
onSend: () -> Unit = {}, onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null, canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null, supportsReasoning: Boolean? = null,
@@ -102,6 +103,7 @@ fun ComposerToolbar(
fastSupported: Boolean = false, fastSupported: Boolean = false,
isFastModeChanging: Boolean = false, isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {}, onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette() val palette = composerPalette()
@@ -134,7 +136,11 @@ fun ComposerToolbar(
// Model + reasoning pill — wraps its content inside the free space, // Model + reasoning pill — wraps its content inside the free space,
// pushing the mic/action buttons to the far end // pushing the mic/action buttons to the far end
val modelScrollState = rememberScrollState() val modelScrollState = rememberScrollState()
LaunchedEffect(currentSessionModel) { val modelLabel =
currentSessionModel?.let { model ->
composerModelLabel(model, showProvider = showModelProvider)
} ?: "Model"
LaunchedEffect(modelLabel) {
modelScrollState.scrollTo(0) modelScrollState.scrollTo(0)
} }
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
@@ -158,9 +164,7 @@ fun ComposerToolbar(
.testTag("model_chip"), .testTag("model_chip"),
) { ) {
Text( Text(
// Keeps the provider so same-named models from different providers text = modelLabel,
// stay distinguishable while allowing the full label to be revealed.
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = palette.onControl, color = palette.onControl,
maxLines = 1, maxLines = 1,
@@ -360,7 +364,7 @@ fun ComposerToolbar(
// Flat mic / stop button — only while the action button is in send mode // Flat mic / stop button — only while the action button is in send mode
AnimatedVisibility( AnimatedVisibility(
visible = canSend, visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f), enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 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 // Action button — send when a send is possible, mic / stop otherwise
FilledIconButton( FilledIconButton(
onClick = if (canSend) onSend else onMicTap, onClick = if (showSend) onSend else onMicTap,
enabled = canSend || isConnected, enabled = if (showSend) canSend else isConnected,
colors = colors =
if (!canSend && isListening) { if (!showSend && isListening) {
listeningIconButtonColors() listeningIconButtonColors()
} else { } else {
IconButtonDefaults.filledIconButtonColors( IconButtonDefaults.filledIconButtonColors(
@@ -398,7 +402,7 @@ fun ComposerToolbar(
.size(ControlSize) .size(ControlSize)
.testTag( .testTag(
when { when {
canSend -> "send_button" showSend -> "send_button"
isListening -> "mic_stop_button" isListening -> "mic_stop_button"
else -> "mic_button" else -> "mic_button"
}, },
@@ -407,7 +411,7 @@ fun ComposerToolbar(
Crossfade( Crossfade(
targetState = targetState =
when { when {
canSend -> ActionGlyph.SEND showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE 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" * 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. * 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('/') val slash = sessionModel.indexOf('/')
if (slash <= 0) return sessionModel if (slash <= 0) return sessionModel
val provider = sessionModel.substring(0, slash)
if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) { val rawProvider = sessionModel.substring(0, slash)
return sessionModel 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:" 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 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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). * Backwards-compatible wrapper delegating to [InlineMediaPlayer].
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
*/ */
@Composable @Composable
fun InlineVideoPlayer( fun InlineVideoPlayer(
@@ -36,65 +12,11 @@ fun InlineVideoPlayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFullScreenClick: () -> Unit = {}, onFullScreenClick: () -> Unit = {},
) { ) {
Box( InlineMediaPlayer(
modifier = uri = videoUri,
modifier modifier = modifier,
.fillMaxWidth() onFullScreenClick = onFullScreenClick,
.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,
)
}
}
}
} }
/** Formats milliseconds into `M:SS` or `H:MM:SS` */ /** 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -57,6 +55,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
// ── ReasoningCard ───────────────────────────────────────────────────────── // ── ReasoningCard ─────────────────────────────────────────────────────────
@@ -317,7 +318,15 @@ fun CodeBlockCard(
} }
} }
// Code content with syntax highlighting // 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(
text = highlighted, text = highlighted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -338,6 +347,7 @@ private val HIGHLIGHT_TOKENS =
listOf( listOf(
TokenPattern(Regex("""//[^\n]*"""), CodeComment), TokenPattern(Regex("""//[^\n]*"""), CodeComment),
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
@@ -354,7 +364,7 @@ private val HIGHLIGHT_TOKENS =
), ),
CodeKeyword, CodeKeyword,
), ),
TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation), TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
) )
/** /**
@@ -1,382 +1,17 @@
package com.m57.hermescontrol.ui.chat.components 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.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 @Composable
fun VideoViewerDialog( fun VideoViewerDialog(
videoUri: String, videoUri: String,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val context = LocalContext.current MediaViewerDialog(
val scope = rememberCoroutineScope() mediaUri = videoUri,
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(
onDismissRequest = onDismissRequest, 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) 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. */ /** Expanded body text. */
fun detail(call: ToolCall): String? = null 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. */ /** Structured extras (streams, diffs, search hits) for special row layouts. */
fun extras( fun extras(
call: ToolCall, call: ToolCall,
@@ -64,7 +64,13 @@ object ToolViewBuilder {
val detailBody = val detailBody =
ToolJson.stripDividerLines( 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 = val detail =
if (error.isNotEmpty()) { if (error.isNotEmpty()) {
@@ -79,7 +79,7 @@ internal object FileEditSupport {
var added = 0 var added = 0
var removed = 0 var removed = 0
for (line in diff.split("\n")) { for (line in diff.lineSequence()) {
when { when {
line.startsWith("+") && !line.startsWith("+++") -> added += 1 line.startsWith("+") && !line.startsWith("+++") -> added += 1
line.startsWith("-") && !line.startsWith("---") -> removed += 1 line.startsWith("-") && !line.startsWith("---") -> removed += 1
@@ -105,14 +105,12 @@ internal object SkillManageRenderer : ToolRenderer {
val msg = ToolJson.firstString(call.result, listOf("message")) val msg = ToolJson.firstString(call.result, listOf("message"))
val failed = ToolJson.boolFalse(call.result?.get("success")) 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 { return when {
!failed -> "❌ Skill operation failed" failed -> "❌ ${msg.ifEmpty { "Skill operation failed" }}"
msg.isNotEmpty() -> "✅ $msg" succeeded -> "✅ ${msg.ifEmpty { "Skill operation done" }}"
else -> "✅ 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 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 { override fun detail(call: ToolCall): String {
val result = call.result val result = call.result
val error = ToolJson.firstString(result, listOf("error")) val error = ToolJson.firstString(result, listOf("error"))
@@ -248,7 +253,7 @@ internal object ProcessRenderer : ToolRenderer {
}.joinToString("\n\n") }.joinToString("\n\n")
} }
val output = ToolJson.firstString(result, listOf("output")) val output = ToolJson.firstString(result, listOf("output", "output_preview"))
if (output.isNotEmpty()) { if (output.isNotEmpty()) {
val status = ToolJson.firstString(result, listOf("status")) val status = ToolJson.firstString(result, listOf("status"))
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output" 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.ViewModel
import androidx.lifecycle.viewModelScope 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.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub import com.m57.hermescontrol.data.ws.ChangeEventHub
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch 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. * 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. * Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
.collect { _ -> .collect { _ ->
if (refreshInFlight) return@collect if (refreshInFlight) return@collect
refreshInFlight = true refreshInFlight = true
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
try { try {
// No Dispatchers.IO hop — see safeLaunchLoad.
val result = apiCall() val result = apiCall()
coroutineContext.ensureActive()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@collect
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
onSuccess(result.data) onSuccess(result.data)
} }
@@ -99,7 +99,7 @@ fun ConfigScreen(
title = { Text(stringResource(R.string.config_screen_title)) }, title = { Text(stringResource(R.string.config_screen_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAll() }, onRefresh = { viewModel.loadAll(forceRefresh = true) },
actions = { actions = {
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) { if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
IconButton(onClick = { viewModel.saveConfig() }) { IconButton(onClick = { viewModel.saveConfig() }) {
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.ConfigSchemaResponse
import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.ConfigUpdateRequest
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -50,35 +54,114 @@ class ConfigViewModel :
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow() val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
private val pendingChanges = mutableMapOf<String, JsonElement>() 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 { 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() loadAll()
} }
fun loadAll() { private fun onDataScopeChanged(newScope: DataScope?) {
viewModelScope.launch { 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) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
viewModelScope.launch {
try { try {
coroutineScope { coroutineScope {
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } } 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 = 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 rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val configResult = configDeferred.await() val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await() val schema: ConfigSchemaResponse? =
val defaultsResult = defaultsDeferred.await() (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 rawResult = rawDeferred.await()
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@coroutineScope
if (configResult is NetworkResult.Success) { if (configResult is NetworkResult.Success) {
val values = flattenConfig(configResult.data) 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 val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update { _uiState.update { state ->
it.copy( 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, isLoading = false,
values = values, values = values,
schema = schema, schema = schema,
@@ -89,15 +172,11 @@ class ConfigViewModel :
schema?.fields?.keys ?: emptySet(), schema?.fields?.keys ?: emptySet(),
), ),
path = path, path = path,
activeCategory = activeCategory = validCategory,
if (schema?.category_order?.isNotEmpty() == true) { errorMessage = null,
schema.category_order.first()
} else {
""
},
) )
} }
} else { } else if (!hasCachedData) {
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error" val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
_uiState.update { _uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg") it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
@@ -105,8 +184,11 @@ class ConfigViewModel :
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}") 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() { fun saveConfig() {
if (pendingChanges.isEmpty()) return if (pendingChanges.isEmpty()) return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(isSaving = true) } _uiState.update { it.copy(isSaving = true) }
viewModelScope.launch { viewModelScope.launch {
val changeset = buildChangeset(pendingChanges) 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) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
pendingChanges.clear() pendingChanges.clear()
@@ -216,6 +301,8 @@ class ConfigViewModel :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() } safeApiCall { ApiClient.hermesApi.getConfig() }
} }
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, isSaving = false,
@@ -242,6 +329,7 @@ class ConfigViewModel :
fun saveYamlConfig() { fun saveYamlConfig() {
val yamlText = _uiState.value.yamlText ?: return val yamlText = _uiState.value.yamlText ?: return
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { it.copy(yamlIsSaving = true) } _uiState.update { it.copy(yamlIsSaving = true) }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
@@ -252,12 +340,16 @@ class ConfigViewModel :
) )
} }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScope != requestScope) return@launch
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val configResult = val configResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfig() } safeApiCall { ApiClient.hermesApi.getConfig() }
} }
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
_uiState.update { _uiState.update {
it.copy( it.copy(
yamlIsSaving = false, yamlIsSaving = false,
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R 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.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJob
@@ -82,10 +83,12 @@ fun CronJobsScreen(
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() }, viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
var selectedJob by remember { mutableStateOf<CronJob?>(null) } var selectedJob by remember { mutableStateOf<CronJob?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadCronJobs() viewModel.loadCronJobs()
} }
@@ -95,7 +98,7 @@ fun CronJobsScreen(
title = { Text(stringResource(R.string.screen_cron)) }, title = { Text(stringResource(R.string.screen_cron)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadCronJobs() }, onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
actions = { actions = {
IconButton(onClick = { viewModel.openNewJobDialog() }) { IconButton(onClick = { viewModel.openNewJobDialog() }) {
Icon( Icon(
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob 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.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -95,6 +99,19 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow() val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null 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 { init {
// Issue #784: gateway broadcasts cron.changed — refresh the list // Issue #784: gateway broadcasts cron.changed — refresh the list
@@ -102,14 +119,24 @@ class CronJobsViewModel :
refreshOnChange( refreshOnChange(
eventType = ChangeEvents.CRON, eventType = ChangeEvents.CRON,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } }, 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 = loadJob =
safeLaunchLoad( safeLaunchSwrLoad(
cache = jobsCache,
forceRefresh = forceRefresh,
currentJob = loadJob, currentJob = loadJob,
onCacheHit = { cached ->
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
},
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
@@ -128,6 +155,7 @@ class CronJobsViewModel :
fun pauseCronJob(id: String) { fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
jobs = jobs =
@@ -141,14 +169,24 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) } 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}") 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) { fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
jobs = jobs =
@@ -162,8 +200,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) } 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}") 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) { fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id }) state.copy(jobs = state.jobs.filter { it.id != id })
} }
@@ -196,8 +244,17 @@ class CronJobsViewModel :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) } 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}") 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) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
closeEditor() closeEditor()
loadCronJobs() loadCronJobs(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ManagedFileEntry import com.m57.hermescontrol.data.model.ManagedFileEntry
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
@@ -79,6 +81,7 @@ fun FilesScreen(
viewModel: FilesViewModel = viewModel { FilesViewModel() }, viewModel: FilesViewModel = viewModel { FilesViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val uploadLauncher = val uploadLauncher =
@@ -104,6 +107,13 @@ fun FilesScreen(
onDispose { viewModel.clearTransientState() } 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() val hasEntries = state.entries.isNotEmpty()
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
import android.util.Base64 import android.util.Base64
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
import com.m57.hermescontrol.data.model.ManagedFileActionResponse import com.m57.hermescontrol.data.model.ManagedFileActionResponse
import com.m57.hermescontrol.data.model.ManagedFileDelete import com.m57.hermescontrol.data.model.ManagedFileDelete
import com.m57.hermescontrol.data.model.ManagedFileEntry 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -76,8 +79,52 @@ class FilesViewModel :
private val _uiState = MutableStateFlow(FilesUiState()) private val _uiState = MutableStateFlow(FilesUiState())
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow() val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
fun load(path: String? = null) { private val filesCache = SwrCache<String, ManagedFilesListResponse>()
safeLaunchLoad(
/**
* 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 = { apiCall = {
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) } 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) { fun navigateTo(entry: ManagedFileEntry) {
if (!entry.isDirectory) return if (!entry.isDirectory) return
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -51,9 +52,11 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() }, viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) { LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadStatus() viewModel.loadStatus()
} }
@@ -63,7 +66,7 @@ fun GatewayScreen(
title = { Text(stringResource(R.string.screen_gateway)) }, title = { Text(stringResource(R.string.screen_gateway)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadStatus() }, onRefresh = { viewModel.loadStatus(forceRefresh = true) },
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.status == null -> { state.isLoading && state.status == null -> {
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -28,8 +30,19 @@ class GatewayViewModel :
private val _uiState = MutableStateFlow(GatewayUiState()) private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow() val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() { private val statusCache = SwrCache<String, StatusResponse>()
safeLaunchLoad(
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() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
@@ -73,7 +86,7 @@ class GatewayViewModel :
toastMessage = "Gateway ${actionName}ed successfully", toastMessage = "Gateway ${actionName}ed successfully",
) )
} }
loadStatus() loadStatus(forceRefresh = true)
} }
is NetworkResult.Failure -> { 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.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard 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.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject 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.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -168,34 +172,71 @@ class KanbanViewModel(
val savedSlug = preferences.getSelectedBoard(endpoint) val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug 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() loadProfiles()
loadProjects() loadProjects()
loadOrchestration() loadOrchestration()
loadModelOptions() loadModelOptions()
viewModelScope.launch { viewModelScope.launch {
when (val result = repository.getBoards(includeArchived = false)) { coroutineScope {
is NetworkResult.Success -> { val prefetchedBoardDeferred =
val boards = result.data.boards if (previouslySelectedId != null) {
_uiState.update { it.copy(isLoading = false, boards = boards) } async(Dispatchers.IO) {
val targetBoard = repository.getBoard(
boards.find { it.id == previouslySelectedId } board = previouslySelectedId,
?: boards.find { it.id == result.data.current } includeArchived = _uiState.value.includeArchived,
?: boards.find { it.id.equals("default", ignoreCase = true) } )
?: boards.firstOrNull() }
if (targetBoard != null) {
selectBoard(targetBoard)
} else { } else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) } null
} }
}
is NetworkResult.Failure -> { when (val result = repository.getBoards(includeArchived = false)) {
_uiState.update { is NetworkResult.Success -> {
it.copy( val boards = result.data.boards
isLoading = false, val targetBoard =
errorMessage = "Failed to load Kanban boards: ${result.error.message}", 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, 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 if (gen != currentLoadGen) return
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -928,6 +965,7 @@ class KanbanViewModel(
isLoading = false, isLoading = false,
columns = body.columns, columns = body.columns,
tasks = allTasks, 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) { fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) } _uiState.update { it.copy(toastMessage = message) }
} }
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -114,6 +115,7 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() }, viewModel: KeysViewModel = viewModel { KeysViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FILTER_ALL) } var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
@@ -150,7 +152,8 @@ fun KeysScreen(
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() } val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
LaunchedEffect(Unit) { LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadKeys() viewModel.loadKeys()
} }
@@ -211,7 +214,7 @@ fun KeysScreen(
title = { Text(stringResource(R.string.screen_keys)) }, title = { Text(stringResource(R.string.screen_keys)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadKeys() }, onRefresh = { viewModel.loadKeys(forceRefresh = true) },
actions = { actions = {
IconButton(onClick = { viewModel.openAddDialog() }) { IconButton(onClick = { viewModel.openAddDialog() }) {
Icon( Icon(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest 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.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -55,6 +57,19 @@ class KeysViewModel :
private val _uiState = MutableStateFlow(KeysUiState()) private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow() 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( private fun buildCategoryList(
envVars: Map<String, EnvVarConfig>, envVars: Map<String, EnvVarConfig>,
expandedCategories: Set<String> = expandedCategories: Set<String> =
@@ -89,8 +104,21 @@ class KeysViewModel :
} }
} }
fun loadKeys() { fun loadKeys(forceRefresh: Boolean = false) {
safeLaunchLoad( 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() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
@@ -178,7 +206,7 @@ class KeysViewModel :
toastMessage = "Key added successfully", toastMessage = "Key added successfully",
) )
} }
loadKeys() loadKeys(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -223,7 +251,7 @@ class KeysViewModel :
toastMessage = "Key deleted successfully", toastMessage = "Key deleted successfully",
) )
} }
loadKeys() loadKeys(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -281,7 +309,7 @@ class KeysViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") } _uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
loadKeys() loadKeys(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
@@ -70,6 +71,7 @@ fun LogsScreen(
viewModel: LogsViewModel = viewModel { LogsViewModel() }, viewModel: LogsViewModel = viewModel { LogsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -115,7 +117,8 @@ fun LogsScreen(
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(dataScope) {
viewModel.clearScopeOwnedState()
viewModel.loadLogs() viewModel.loadLogs()
} }
@@ -146,7 +149,7 @@ fun LogsScreen(
title = { Text(stringResource(R.string.screen_logs)) }, title = { Text(stringResource(R.string.screen_logs)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadLogs() }, onRefresh = { viewModel.loadLogs(forceRefresh = true) },
actions = { actions = {
// Pause / Resume toggle // Pause / Resume toggle
IconButton(onClick = { pauseScroll = !pauseScroll }) { IconButton(onClick = { pauseScroll = !pauseScroll }) {
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,43 +42,51 @@ class LogsViewModel :
private val _uiState = MutableStateFlow(LogsUiState()) private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow() val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
@Volatile private var loadJob: Job? = null
private var loadInProgress = false
private var autoRefreshJob: Job? = null private var autoRefreshJob: Job? = null
private val logsCache = SwrCache<String, LogResponse>()
fun loadLogs() { fun clearScopeOwnedState() {
if (loadInProgress) return _uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
loadInProgress = true }
fun loadLogs(forceRefresh: Boolean = false) {
val filters = _uiState.value.filters val filters = _uiState.value.filters
safeLaunchLoad( val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
apiCall = { loadJob =
safeApiCall { safeLaunchSwrLoad(
ApiClient.hermesApi.getLogs( cache = logsCache,
file = filters.file, cacheKey = cacheKey,
lines = filters.lines, forceRefresh = forceRefresh,
level = filters.level, currentJob = loadJob,
component = filters.component, onCacheHit = { data ->
) val logsList = data.lines ?: data.logs ?: emptyList()
} _uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
}, },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, apiCall = {
onSuccess = { data -> safeApiCall {
val body = data ApiClient.hermesApi.getLogs(
val logsList = body.lines ?: body.logs ?: emptyList() file = filters.file,
_uiState.update { it.copy(isLoading = false, logs = logsList) } lines = filters.lines,
loadInProgress = false level = filters.level,
}, component = filters.component,
onError = { errorMsg -> )
_uiState.update { }
it.copy( },
isLoading = false, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
errorMessage = "Failed to load logs: $errorMsg", onSuccess = { data ->
) val logsList = data.lines ?: data.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",
)
}
},
)
} }
/** Update the server-side filters and immediately reload. */ /** 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R 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.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
@@ -99,6 +100,7 @@ fun McpServersScreen(
viewModel: McpServersViewModel = viewModel { McpServersViewModel() }, viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboardManager.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) ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) }, title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers() }, onRefresh = { viewModel.loadServers(forceRefresh = true) },
actions = { actions = {
IconButton( IconButton(
onClick = { viewModel.testAllServers() }, onClick = { viewModel.testAllServers() },
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.AddMcpServerRequest import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest 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.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -69,10 +72,39 @@ class McpServersViewModel :
private val _uiState = MutableStateFlow(McpServersUiState()) private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow() 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 ────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────
fun loadServers() { fun loadServers(forceRefresh: Boolean = false) {
safeLaunchLoad( 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() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
@@ -247,7 +279,7 @@ class McpServersViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") } _uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
loadServers() loadServers(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -305,7 +337,9 @@ class McpServersViewModel :
toastMessage = msg, toastMessage = msg,
) )
} }
loadServers() if (successCount > 0) {
loadServers(forceRefresh = true)
}
} }
} }
@@ -371,7 +405,7 @@ class McpServersViewModel :
toastMessage = "Server '${request.name}' added", toastMessage = "Server '${request.name}' added",
) )
} }
loadServers() loadServers(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -428,7 +462,7 @@ class McpServersViewModel :
toastMessage = "Env var '$key' added", toastMessage = "Env var '$key' added",
) )
} }
loadServers() loadServers(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -453,7 +487,7 @@ class McpServersViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") } _uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
loadServers() loadServers(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -520,7 +554,7 @@ class McpServersViewModel :
toastMessage = "Catalog entry '${entry.name}' installed", toastMessage = "Catalog entry '${entry.name}' installed",
) )
} }
loadServers() loadServers(forceRefresh = true)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -605,7 +639,7 @@ class McpServersViewModel :
toastMessage = "OAuth authorization successful!", toastMessage = "OAuth authorization successful!",
) )
} }
loadServers() loadServers(forceRefresh = true)
} }
"error" -> { "error" -> {
@@ -112,7 +112,7 @@ fun MemoryScreen(
title = { Text(stringResource(R.string.screen_memory)) }, title = { Text(stringResource(R.string.screen_memory)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.load() }, onRefresh = { viewModel.load(forceRefresh = true) },
modifier = modifier, modifier = modifier,
) { paddingValues -> ) { paddingValues ->
when { when {
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResetRequest import com.m57.hermescontrol.data.model.MemoryResetRequest
import com.m57.hermescontrol.data.model.MemoryResponse 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.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.ui.common.ToastHost 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -39,50 +45,89 @@ class MemoryViewModel :
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow() val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
init { 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 { viewModelScope.launch {
ProfileSwitchCoordinator.switched.collect { ProfileSwitchCoordinator.switched.collect {
load(silent = true) onDataScopeChanged(AuthManager.currentDataScope())
} }
} }
viewModelScope.launch { viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched.collect { 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 (_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) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
} }
viewModelScope.launch { loadJob =
val result = safeApiCall { ApiClient.hermesApi.getMemory() } viewModelScope.launch {
when (result) { coroutineScope {
is NetworkResult.Success -> { val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
_uiState.update { it.copy(isLoading = false, memory = result.data) } val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
}
is NetworkResult.Failure -> { val memoryResult = memoryDeferred.await()
_uiState.update { val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
it.copy( if (currentScope != requestScope) return@coroutineScope
isLoading = false,
errorMessage = result.error.message, 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) { 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.animation.animateContentSize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -98,15 +100,15 @@ fun ModelScreen(
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_models)) }, title = { Text(stringResource(R.string.screen_models)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading || state.catalogLoading,
onRefresh = { viewModel.loadAll(refresh = true) }, onRefresh = { viewModel.loadAll(refresh = true) },
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.providers.isEmpty() -> { state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues)) SkeletonListState(modifier = Modifier.padding(paddingValues))
} }
state.errorMessage != null -> { state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() }, onRetry = { viewModel.loadAll() },
@@ -114,7 +116,10 @@ fun ModelScreen(
) )
} }
state.providers.isEmpty() -> { !state.isLoading &&
!state.catalogLoading &&
state.providers.isEmpty() &&
state.mainModelProvider.isEmpty() -> {
EmptyState( EmptyState(
title = stringResource(R.string.model_empty_title), title = stringResource(R.string.model_empty_title),
subtitle = stringResource(R.string.model_empty_desc), subtitle = stringResource(R.string.model_empty_desc),
@@ -191,24 +196,38 @@ fun ModelScreen(
} }
// ── Provider List ── // ── Provider List ──
items(filteredProviders, key = { it.slug }) { provider -> if (state.catalogLoading && state.providers.isEmpty()) {
val isExpanded = expandedProviderSlug == provider.slug item {
val isCurrent = provider.is_current == true 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( ProviderCard(
provider = provider, provider = provider,
isExpanded = isExpanded, isExpanded = isExpanded,
isCurrent = isCurrent, isCurrent = isCurrent,
query = query, query = query,
activeProfile = state.activeProfile, activeProfile = state.activeProfile,
pinnedModels = state.pinnedModels, pinnedModels = state.pinnedModels,
onToggleExpand = { onToggleExpand = {
expandedProviderSlug = if (isExpanded) null else provider.slug expandedProviderSlug = if (isExpanded) null else provider.slug
}, },
onModelClick = { slug, model -> viewModel.selectModel(slug, model) }, onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
onPin = { slug, model -> viewModel.pinModel(slug, model) }, onPin = { slug, model -> viewModel.pinModel(slug, model) },
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) }, onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
) )
}
} }
} }
} }
@@ -241,7 +260,7 @@ fun ModelScreen(
ModelPickerDialog( ModelPickerDialog(
providers = state.providers, providers = state.providers,
title = stringResource(R.string.model_set_main), title = stringResource(R.string.model_set_main),
isLoading = state.isLoading && state.providers.isEmpty(), isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
pinnedModels = state.pinnedModels, pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model -> onSelect = { provider, model ->
@@ -267,7 +286,7 @@ fun ModelScreen(
ModelPickerDialog( ModelPickerDialog(
providers = state.providers, providers = state.providers,
title = stringResource(R.string.model_set_aux, state.auxPickerTask), 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, pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { 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