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:
1 parent
c299dd01b2
commit
67097c4d1e
179 files changed
+14930
-1737
No files matched your search
@@ -162,7 +162,7 @@ jobs:
|
||||
needs: [changes]
|
||||
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -91,6 +91,21 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
|
||||
|
||||
_Note: For release builds, ensure keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) are configured, or let the GitHub Actions release workflow handle it on tag push (`v*`)._
|
||||
|
||||
### Nix emulator
|
||||
|
||||
The Nix development shell enables physical keyboard input in an existing
|
||||
`hermes_dev` AVD configuration. It respects `ANDROID_AVD_HOME` and
|
||||
`ANDROID_USER_HOME`, defaulting to `~/.android/avd`.
|
||||
|
||||
Close any running emulator, then cold boot once to apply the setting:
|
||||
|
||||
```bash
|
||||
nix develop --command emulator -avd hermes_dev -no-snapshot-load
|
||||
```
|
||||
|
||||
Subsequent launches can omit `-no-snapshot-load`. Create the `hermes_dev` AVD
|
||||
first if it does not exist; the shell does not create one.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -176,6 +176,11 @@ dependencies {
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Media3 (ExoPlayer)
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
|
||||
// Encrypted storage
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "4f44d92f9b8db2b02a1b156b37d64651",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "chat_messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionId",
|
||||
"columnName": "session_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reasoningText",
|
||||
"columnName": "reasoning_text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolName",
|
||||
"columnName": "tool_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolCallId",
|
||||
"columnName": "tool_call_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolStatus",
|
||||
"columnName": "tool_status",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStreaming",
|
||||
"columnName": "is_streaming",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayKind",
|
||||
"columnName": "display_kind",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tokenCount",
|
||||
"columnName": "token_count",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tps",
|
||||
"columnName": "tps",
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "completionId",
|
||||
"columnName": "completion_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_chat_messages_session_id_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4f44d92f9b8db2b02a1b156b37d64651')"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,77 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Issue #1163: execute the wire parser on ART, without backend credentials or model calls. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StreamingEventDeviceTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun streamingBurstPreservesEveryChunkAndSessionOnDevice() {
|
||||
val types = listOf("message.token", "message.delta", "thinking.delta", "reasoning.delta")
|
||||
for (type in types) {
|
||||
val actual = StringBuilder()
|
||||
val expected = StringBuilder()
|
||||
repeat(1000) { seq ->
|
||||
val frame =
|
||||
"""{"method":"event","params":{"type":"$type","seq":$seq,""" +
|
||||
""""payload":{"session_id":"device-test","text":"chunk-$seq ../ \uD83C\uDF19\n",""" +
|
||||
""""extra":{"nested":[{"text":"ignored","path":"../ignored"}]}}}}"""
|
||||
val response = Json.decodeFromString<JsonRpcResponse>(frame)
|
||||
val event = EventParser.parse(response, frame)
|
||||
val (token, session) =
|
||||
when (event) {
|
||||
is WsEvent.MessageToken -> event.token to event.sessionId
|
||||
is WsEvent.ThinkingDelta -> event.token to event.sessionId
|
||||
is WsEvent.ReasoningDelta -> event.token to event.sessionId
|
||||
else -> error("Unexpected streaming event: $event")
|
||||
}
|
||||
assertEquals("device-test", session)
|
||||
actual.append(token)
|
||||
expected.append("chunk-$seq ../ 🌙\n")
|
||||
}
|
||||
assertEquals(type, expected.toString(), actual.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reasoningFastPathAvoidsContainerTraversalAndRendersOnDevice() {
|
||||
val fields =
|
||||
mapOf<String, JsonElement>(
|
||||
"type" to JsonPrimitive("reasoning.delta"),
|
||||
"payload" to JsonObject(mapOf("text" to JsonPrimitive("Device streaming verified"))),
|
||||
)
|
||||
val params =
|
||||
JsonObject(
|
||||
object : Map<String, JsonElement> by fields {
|
||||
override val entries: Set<Map.Entry<String, JsonElement>>
|
||||
get() = error("Streaming parser traversed the params tree")
|
||||
},
|
||||
)
|
||||
val event = EventParser.parse(JsonRpcResponse(method = "event", params = params)) as WsEvent.ReasoningDelta
|
||||
compose.setContent {
|
||||
HermesControlTheme {
|
||||
ReasoningCard(reasoningText = event.token)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("reasoning_card").performClick()
|
||||
compose.onNodeWithText("Device streaming verified").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.notification.ChatNotificationService
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class ChatReadNotificationTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private val testChannelId = "test_chat_channel"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.uiAutomation
|
||||
.grantRuntimePermission(
|
||||
context.packageName,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
testChannelId,
|
||||
"Test Chat Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
notificationManager.cancelAll()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
notificationManager.cancelAll()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
private fun awaitNotification(
|
||||
timeoutMs: Long = 3000L,
|
||||
predicate: (Array<StatusBarNotification>) -> Boolean,
|
||||
): Boolean {
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < timeoutMs) {
|
||||
if (predicate(notificationManager.activeNotifications)) return true
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return predicate(notificationManager.activeNotifications)
|
||||
}
|
||||
|
||||
private fun postRealNotification(
|
||||
kind: String,
|
||||
scopeId: String = "default",
|
||||
sessionId: String = "session-1",
|
||||
completionId: String = "comp-1",
|
||||
textSnippet: String = "Here is the reply",
|
||||
serverMessageId: Int? = null,
|
||||
): Long {
|
||||
val gen =
|
||||
if (kind == ReplyNotificationTracker.KIND_REPLY) {
|
||||
ReplyNotificationTracker.registerPendingReply(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
)
|
||||
} else {
|
||||
ReplyNotificationTracker.nextGeneration()
|
||||
}
|
||||
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(context, testChannelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Agent")
|
||||
.setContentText(textSnippet)
|
||||
.addExtras(
|
||||
Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, kind)
|
||||
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
|
||||
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, textSnippet)
|
||||
putLong(ReplyNotificationTracker.EXTRA_GENERATION, gen)
|
||||
serverMessageId?.let {
|
||||
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
|
||||
}
|
||||
},
|
||||
).build()
|
||||
|
||||
when (kind) {
|
||||
ReplyNotificationTracker.KIND_REPLY -> {
|
||||
ReplyNotificationTracker.postReplyNotification(context, notification, gen)
|
||||
}
|
||||
|
||||
ReplyNotificationTracker.KIND_ACTION -> {
|
||||
ReplyNotificationTracker.postActionNotification(context, notification)
|
||||
}
|
||||
|
||||
ReplyNotificationTracker.KIND_REPLIED -> {
|
||||
ReplyNotificationTracker.postRepliedNotification(context, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val active = awaitNotification { sbns -> sbns.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue("Notification ID 2 must be active in system after posting", active)
|
||||
return gen
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_dismissesRealSystemNotification_whenAssistantMessageDisplayed() {
|
||||
val sessionId = "session-read-test"
|
||||
val completionId = "comp-12345"
|
||||
val replyText = "Here is the completed response."
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = replyText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val dismissed =
|
||||
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue(
|
||||
"Notification ID 2 must be dismissed from real system notifications when message is viewed",
|
||||
dismissed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile-produced durable row id must survive in the real posted
|
||||
* notification; after a process death it is the only thing that can name the
|
||||
* row whose visibility dismisses this notification.
|
||||
*/
|
||||
@Test
|
||||
fun serverRowIdSurvivesProcessDeathThroughRealNotificationExtras() {
|
||||
val sessionId = "session-exact-row-test"
|
||||
val replyText = "Done"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = "comp-exact-row",
|
||||
textSnippet = replyText,
|
||||
serverMessageId = 52,
|
||||
)
|
||||
|
||||
// Simulate process death: nothing in memory, only the posted extras.
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val recovered = ReplyNotificationTracker.getActiveTarget(context)
|
||||
assertEquals(
|
||||
"Recovered target must carry the durable row id from the notification extras",
|
||||
52,
|
||||
recovered?.serverMessageId,
|
||||
)
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
|
||||
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
|
||||
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
|
||||
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val dismissed =
|
||||
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue(
|
||||
"Viewing the exact hydrated row must dismiss the real system notification",
|
||||
dismissed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forActionRequest() {
|
||||
val sessionId = "session-action-test"
|
||||
val completionId = "comp-action"
|
||||
val promptText = "Approval required"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_ACTION,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = promptText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = promptText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Action notifications must NOT be auto-dismissed when viewing chat",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forRepliedNotification() {
|
||||
val sessionId = "session-replied-test"
|
||||
val completionId = "comp-replied"
|
||||
val replyStatusText = "Replied: done"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLIED,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = replyStatusText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyStatusText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Replied status notifications must NOT be auto-dismissed when viewing chat",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissNotification_forDifferentSession() {
|
||||
val sessionIdWithNotif = "session-with-notif"
|
||||
val differentSessionId = "other-session"
|
||||
val completionId = "comp-12345"
|
||||
val replyText = "Here is the completed response."
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionIdWithNotif,
|
||||
completionId = completionId,
|
||||
textSnippet = replyText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = differentSessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = differentSessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Notification ID 2 must remain active for a different session",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipe
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaViewerDeviceTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun open(
|
||||
asset: String,
|
||||
mime: String,
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val file = File(instrumentation.targetContext.cacheDir, asset)
|
||||
instrumentation.context.assets
|
||||
.open(asset)
|
||||
.use { input -> file.outputStream().use(input::copyTo) }
|
||||
compose.setContent {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
HermesControlTheme {
|
||||
if (visible) {
|
||||
MediaViewerDialog(file.toURI().toString(), { visible = false }, asset, mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitDescription(resource: Int) {
|
||||
val text = InstrumentationRegistry.getInstrumentation().targetContext.getString(resource)
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithContentDescription(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitTag(tag: String) {
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPlayback(
|
||||
asset: String,
|
||||
mime: String,
|
||||
) {
|
||||
open(asset, mime)
|
||||
awaitDescription(R.string.media_player_pause)
|
||||
awaitTag("media_play_pause_button")
|
||||
compose.onNodeWithTag("media_play_pause_button").performClick()
|
||||
awaitDescription(R.string.media_player_play)
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
instrumentation.uiAutomation.takeScreenshot().let { bitmap ->
|
||||
File(instrumentation.targetContext.cacheDir, "$asset.png").outputStream().use {
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
compose.onNodeWithTag("media_seek_slider").performTouchInput {
|
||||
swipe(start = center.copy(x = width * 0.2f), end = center.copy(x = width * 0.9f))
|
||||
}
|
||||
compose.waitUntil(5_000) {
|
||||
val progress =
|
||||
compose
|
||||
.onNodeWithTag("media_seek_slider")
|
||||
.fetchSemanticsNode()
|
||||
.config[SemanticsProperties.ProgressBarRangeInfo]
|
||||
progress.current > progress.range.endInclusive * 0.75f
|
||||
}
|
||||
awaitTag("media_play_pause_button")
|
||||
compose.onNodeWithTag("media_play_pause_button").performClick()
|
||||
awaitDescription(R.string.media_player_replay)
|
||||
awaitTag("media_play_pause_button")
|
||||
compose.onNodeWithTag("media_play_pause_button").performClick()
|
||||
awaitDescription(R.string.media_player_pause)
|
||||
awaitTag("media_close_button")
|
||||
compose.onNodeWithTag("media_close_button").performClick()
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-tone.wav", "audio/wav")
|
||||
|
||||
@Test
|
||||
fun videoPlaysPausesSeeksReplaysAndCloses() = checkPlayback("player-video.mp4", "video/mp4")
|
||||
|
||||
@Test
|
||||
fun saveButtonStreamsToDownloadsAndVerifiesMediaStoreItem() {
|
||||
open("player-tone.wav", "audio/wav")
|
||||
awaitTag("media_save_button")
|
||||
compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
|
||||
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val resolver = instrumentation.targetContext.contentResolver
|
||||
|
||||
var savedUri: Uri? = null
|
||||
compose.waitUntil(15_000) {
|
||||
val cursor =
|
||||
resolver.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(
|
||||
MediaStore.MediaColumns._ID,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
|
||||
arrayOf("player-tone.wav"),
|
||||
"${MediaStore.MediaColumns._ID} DESC",
|
||||
)
|
||||
cursor?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||
savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||
val name = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
|
||||
val mime = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE))
|
||||
assertEquals("player-tone.wav", name)
|
||||
assertTrue(mime == "audio/wav" || mime == "audio/x-wav")
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
try {
|
||||
assertNotNull(savedUri)
|
||||
val expectedBytes =
|
||||
instrumentation.context.assets
|
||||
.open("player-tone.wav")
|
||||
.use { it.readBytes() }
|
||||
val actualBytes = resolver.openInputStream(savedUri!!)?.use { it.readBytes() }
|
||||
assertArrayEquals(expectedBytes, actualBytes)
|
||||
} finally {
|
||||
savedUri?.let { resolver.delete(it, null, null) }
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("media_close_button").performClick()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shareMediaBuildsValidIntentAndReadableStream() =
|
||||
runBlocking {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val file = File(context.cacheDir, "player-tone.wav")
|
||||
instrumentation.context.assets.open("player-tone.wav").use { input ->
|
||||
file.outputStream().use(input::copyTo)
|
||||
}
|
||||
|
||||
val intent =
|
||||
MediaExportHelper.shareMedia(
|
||||
context = context,
|
||||
uri = file.toURI().toString(),
|
||||
fallbackMime = "audio/wav",
|
||||
displayName = "player-tone.wav",
|
||||
)
|
||||
|
||||
assertNotNull(intent)
|
||||
assertEquals(Intent.ACTION_SEND, intent!!.action)
|
||||
assertEquals("audio/wav", intent.type)
|
||||
assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val streamUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
assertNotNull(streamUri)
|
||||
|
||||
val expectedSize = file.length()
|
||||
val actualSize =
|
||||
context.contentResolver.openInputStream(streamUri!!)?.use { stream ->
|
||||
var count = 0L
|
||||
val buf = ByteArray(8192)
|
||||
while (true) {
|
||||
val r = stream.read(buf)
|
||||
if (r < 0) break
|
||||
count += r
|
||||
}
|
||||
count
|
||||
}
|
||||
assertEquals(expectedSize, actualSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSourceShowsRetryAndCanClose() {
|
||||
compose.setContent {
|
||||
HermesControlTheme {
|
||||
MediaViewerDialog("file:///missing-media-test.mp3", {}, mimeType = "audio/mpeg")
|
||||
}
|
||||
}
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithTag("media_retry_button").assertIsDisplayed().performClick()
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithTag("media_retry_button").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithTag("media_close_button").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -59,6 +59,7 @@ class ComposerInteractionTest {
|
||||
composerWidth: Dp? = null,
|
||||
modelAfterTap: String? = null,
|
||||
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
|
||||
showModelProvider: Boolean = false,
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
@@ -81,6 +82,7 @@ class ComposerInteractionTest {
|
||||
isListening = listening,
|
||||
isAgentTyping = false,
|
||||
isConnected = true,
|
||||
isSessionReady = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
currentSessionModel = currentModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
@@ -92,6 +94,7 @@ class ComposerInteractionTest {
|
||||
onCameraTap = { cameraTaps++ },
|
||||
onImageTap = { photosTaps++ },
|
||||
onFileTap = { fileTaps++ },
|
||||
showModelProvider = showModelProvider,
|
||||
)
|
||||
}
|
||||
if (composerWidth == null) {
|
||||
@@ -205,9 +208,9 @@ class ComposerInteractionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() {
|
||||
fun longModelName_remainsScrollableAndDoesNotTriggerPicker() {
|
||||
val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters"
|
||||
setComposer(model = longModel, composerWidth = 280.dp)
|
||||
setComposer(model = longModel, composerWidth = 280.dp, showModelProvider = true)
|
||||
|
||||
composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
|
||||
@@ -251,7 +254,7 @@ class ComposerInteractionTest {
|
||||
fun changingModel_resetsScrolledLabelToTheStart() {
|
||||
val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters"
|
||||
val secondModel = "anthropic/another-extremely-long-model-name-preview-2026-with-extra-characters"
|
||||
setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp)
|
||||
setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp, showModelProvider = true)
|
||||
|
||||
val firstText = composeTestRule.onNodeWithText(firstModel)
|
||||
val leftBefore = firstText.getUnclippedBoundsInRoot().left
|
||||
@@ -343,4 +346,29 @@ class ComposerInteractionTest {
|
||||
|
||||
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultComposer_showsOnlyModelNameWithoutProvider() {
|
||||
setComposer(model = "openai/gpt-5.5", showModelProvider = false)
|
||||
|
||||
composeTestRule.onNodeWithText("gpt-5.5").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("openai/gpt-5.5").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enabledComposer_showsProviderAndModelName() {
|
||||
setComposer(model = "openai/gpt-5.5", showModelProvider = true)
|
||||
|
||||
composeTestRule.onNodeWithText("openai/gpt-5.5").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("gpt-5.5").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultComposer_showsOnlyFinalModelSegmentForNestedProvider() {
|
||||
setComposer(model = "openrouter/openai/gpt-5", showModelProvider = false)
|
||||
|
||||
composeTestRule.onNodeWithText("gpt-5").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("openai/gpt-5").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("openrouter/openai/gpt-5").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.m57.hermescontrol.ui.settings.components
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The opt-in release-candidate switch on the About tab: stable-only default,
|
||||
* one callback per tap, and no channel flip while an APK is in flight.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class AboutSectionReleaseCandidateTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun stableOnlyByDefault_tapReportsOptIn() {
|
||||
var reported: Boolean? = null
|
||||
composeTestRule.setContent {
|
||||
AboutSection(
|
||||
updateState = AppUpdateState.Idle,
|
||||
checkReleaseCandidateUpdates = false,
|
||||
onCheckReleaseCandidateUpdatesChange = { reported = it },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(RC_TITLE).assertIsDisplayed()
|
||||
composeTestRule.onNode(isToggleable()).assertIsOff()
|
||||
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
|
||||
assertEquals(true, reported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optedIn_switchShowsChecked() {
|
||||
composeTestRule.setContent {
|
||||
AboutSection(
|
||||
updateState = AppUpdateState.Idle,
|
||||
checkReleaseCandidateUpdates = true,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsOn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloading_disablesTheChannelSwitch() {
|
||||
composeTestRule.setContent {
|
||||
AboutSection(
|
||||
updateState = AppUpdateState.Downloading(0.5f),
|
||||
checkReleaseCandidateUpdates = false,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun installing_disablesTheChannelSwitch() {
|
||||
composeTestRule.setContent {
|
||||
AboutSection(
|
||||
updateState = AppUpdateState.Installing("v1.25.0-rc.3"),
|
||||
checkReleaseCandidateUpdates = true,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idle_keepsTheChannelSwitchEnabled() {
|
||||
composeTestRule.setContent {
|
||||
AboutSection(updateState = AppUpdateState.UpToDate("v1.24.2"))
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RC_TITLE = "Check release candidate updates"
|
||||
}
|
||||
}
|
||||
@@ -10,28 +10,41 @@ import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HermesControlApp :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AuthManager.init(this)
|
||||
// Issue #478: guarantee a "Default" profile is always selected so there is no
|
||||
// separate standalone/default code path anywhere in the app.
|
||||
AuthManager.ensureDefaultProfile()
|
||||
NetworkMonitor.init(this)
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||
AnalyticsPreloader.preload(this)
|
||||
// Issue #890: silent once-per-version update check right after launch,
|
||||
// so the chat screen can show an update banner without user interaction.
|
||||
UpdateNoticeManager.checkOnLaunch()
|
||||
SessionListCacheStore.init(this)
|
||||
// Reply-notification turn boundaries survive process death (armed before
|
||||
// each mobile prompt, consumed by the matching completion).
|
||||
TurnCorrelationTracker.attach(this)
|
||||
appScope.launch {
|
||||
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||
AnalyticsPreloader.preload(this@HermesControlApp)
|
||||
// Issue #890: silent once-per-version update check right after launch,
|
||||
// so the chat screen can show an update banner without user interaction.
|
||||
UpdateNoticeManager.checkOnLaunch()
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
|
||||
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
|
||||
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION"
|
||||
}
|
||||
|
||||
private var baseContextLanguageCode: String = LocaleContextWrapper.SYSTEM_LANGUAGE
|
||||
|
||||
/**
|
||||
* Apply the user-selected display language before any view is inflated.
|
||||
* Reads the persisted code from [AuthManager]; an uninitialized store
|
||||
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
|
||||
val code =
|
||||
runCatching { AuthManager.getAppLanguage() }
|
||||
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
|
||||
baseContextLanguageCode = code
|
||||
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
|
||||
}
|
||||
|
||||
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
consumeNotificationIntent(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
AuthManager.initializationState.collect { state ->
|
||||
if (state == AuthManager.InitializationState.Ready) {
|
||||
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
|
||||
@@ -65,7 +89,35 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MainNavigation()
|
||||
val initState by AuthManager.initializationState.collectAsStateWithLifecycle()
|
||||
when (initState) {
|
||||
AuthManager.InitializationState.Loading -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
AuthManager.InitializationState.Ready -> {
|
||||
val savedLanguage = AuthManager.getAppLanguage()
|
||||
if (savedLanguage != baseContextLanguageCode) {
|
||||
// The next activity attaches with loaded preferences. Keep navigation
|
||||
// gated across recompositions until that recreation completes (#1171).
|
||||
LaunchedEffect(savedLanguage) {
|
||||
recreate()
|
||||
}
|
||||
LoadingState()
|
||||
} else {
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
AuthManager.InitializationState.Failed -> {
|
||||
ErrorState(
|
||||
message = stringResource(R.string.auth_init_failed),
|
||||
onRetry = {
|
||||
AuthManager.init(applicationContext)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,9 +134,6 @@ class MainActivity : ComponentActivity() {
|
||||
NotificationHelper.setAppForeground(this, true)
|
||||
ExternalActivityLifecycleGuard.onHostResumed()
|
||||
NotificationHelper.stop(this)
|
||||
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class ServerStore(
|
||||
class ServerStore private constructor(
|
||||
private val dataStore: DataStore<ServerStoreState>,
|
||||
private val scope: CoroutineScope,
|
||||
initial: ServerStoreState,
|
||||
) {
|
||||
private val _stateFlow: MutableStateFlow<ServerStoreState>
|
||||
val stateFlow: StateFlow<ServerStoreState>
|
||||
private val _stateFlow = MutableStateFlow(initial)
|
||||
val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
val initial =
|
||||
runBlocking(Dispatchers.IO) {
|
||||
try {
|
||||
dataStore.data.first().selfHealed()
|
||||
} catch (e: Exception) {
|
||||
ServerStoreSerializer.defaultValue
|
||||
}
|
||||
}
|
||||
_stateFlow = MutableStateFlow(initial)
|
||||
stateFlow = _stateFlow.asStateFlow()
|
||||
companion object {
|
||||
// Do not expose a default snapshot before disk/migrations finish (issue #1171).
|
||||
suspend fun create(
|
||||
dataStore: DataStore<ServerStoreState>,
|
||||
scope: CoroutineScope,
|
||||
): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
|
||||
}
|
||||
|
||||
fun getLatestState(): ServerStoreState = _stateFlow.value
|
||||
|
||||
@@ -28,6 +28,11 @@ data class ServerStoreState(
|
||||
val showUserMessageTokens: Boolean = true,
|
||||
val showAssistantMessageTokens: Boolean = true,
|
||||
val showTokensPerSecond: Boolean = true,
|
||||
val showModelProvider: Boolean = false,
|
||||
val keepConnectedInBackground: Boolean = false,
|
||||
// Opt-in update channel: when true the in-app update check also considers
|
||||
// pre-release release-candidate GitHub releases, not just stable ones.
|
||||
val checkReleaseCandidateUpdates: Boolean = false,
|
||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||
val appLanguage: String = "system",
|
||||
|
||||
@@ -20,9 +20,12 @@ import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Singleton that manages encrypted storage of the Hermes dashboard token
|
||||
@@ -124,37 +127,83 @@ object AuthManager {
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
private val _authGenerationFlow = MutableStateFlow(0L)
|
||||
val authGenerationFlow: StateFlow<Long> = _authGenerationFlow.asStateFlow()
|
||||
|
||||
fun invalidateAuthGeneration() {
|
||||
_authGenerationFlow.value = _authGenerationFlow.value + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical observable data-scope identity covering connection profile, base URL,
|
||||
* active server-side Hermes profile, and in-memory auth generation.
|
||||
*/
|
||||
val dataScopeFlow: StateFlow<DataScope?> =
|
||||
combine(
|
||||
selectedProfileFlow,
|
||||
baseUrlFlow,
|
||||
activeProfileId,
|
||||
authGenerationFlow,
|
||||
) { selectedProfile, baseUrl, activeProfile, authGen ->
|
||||
val cleanUrl = baseUrl.trimEnd('/')
|
||||
if (cleanUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
DataScope(
|
||||
connectionProfileId = selectedProfile ?: DEFAULT_PROFILE_ID,
|
||||
baseUrl = cleanUrl,
|
||||
activeProfileId = activeProfile?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID,
|
||||
inMemoryAuthGeneration = authGen,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = contextScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
*/
|
||||
enum class InitializationState { Loading, Ready, Failed }
|
||||
|
||||
private val _initializationState = MutableStateFlow(InitializationState.Loading)
|
||||
val initializationState: StateFlow<InitializationState> = _initializationState.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var initialization: Deferred<Unit>? = null
|
||||
|
||||
@Volatile
|
||||
private var readyPrefs: SharedPreferences? = null
|
||||
|
||||
private var cachedDatabasePassword: ByteArray? = null
|
||||
|
||||
// Issue #1171: publish readiness only after persisted profiles and credentials are loaded.
|
||||
fun init(context: Context) {
|
||||
if (_serverStore != null) return
|
||||
synchronized(this) {
|
||||
if (_serverStore != null) return
|
||||
|
||||
val dataStore =
|
||||
androidx.datastore.core.DataStoreFactory.create(
|
||||
serializer = ServerStoreSerializer,
|
||||
migrations =
|
||||
listOf(
|
||||
ServerStoreMigration(context),
|
||||
ServerUrlMigration(),
|
||||
),
|
||||
) {
|
||||
context.filesDir.resolve("server_store.json")
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Dispatchers.IO)
|
||||
if (initialization != null && _initializationState.value != InitializationState.Failed) return
|
||||
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
|
||||
appScope?.cancel()
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
appScope = scope
|
||||
val store = ServerStore(dataStore, scope)
|
||||
_serverStore = store
|
||||
_selectedProfileFlow.value = store.getLatestState().selectedProfileId
|
||||
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
|
||||
|
||||
if (prefsDeferred == null) {
|
||||
prefsDeferred =
|
||||
CoroutineScope(Dispatchers.IO).async {
|
||||
_initializationState.value = InitializationState.Loading
|
||||
readyPrefs = null
|
||||
val prefs = CompletableDeferred<SharedPreferences>(scope.coroutineContext[kotlinx.coroutines.Job])
|
||||
prefsDeferred = prefs
|
||||
initialization =
|
||||
scope.async {
|
||||
try {
|
||||
previousJob?.join()
|
||||
val dataStore =
|
||||
androidx.datastore.core.DataStoreFactory.create(
|
||||
serializer = ServerStoreSerializer,
|
||||
migrations = listOf(ServerStoreMigration(context), ServerUrlMigration()),
|
||||
scope = scope,
|
||||
) { context.filesDir.resolve("server_store.json") }
|
||||
val store = ServerStore.create(dataStore, scope)
|
||||
_serverStore = store
|
||||
ensureDefaultProfile()
|
||||
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
val p =
|
||||
EncryptedSharedPreferences.create(
|
||||
@@ -165,52 +214,60 @@ object AuthManager {
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
migrateLegacyDefaultIfNeeded(p)
|
||||
_tokenFlow.value = getTokenInternal(p)
|
||||
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
|
||||
p
|
||||
synchronized(this@AuthManager) {
|
||||
readyPrefs = p
|
||||
cachedToken = getTokenInternal(p)
|
||||
tokenInitialized = true
|
||||
_tokenFlow.value = cachedToken
|
||||
_activeProfileId.value =
|
||||
p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
prefs.complete(p)
|
||||
val state = store.getLatestState()
|
||||
_selectedProfileFlow.value = state.selectedProfileId
|
||||
_baseUrlFlow.value = state.resolvedBaseUrl
|
||||
val profileId = normalizedProfileId(state.selectedProfileId)
|
||||
CookieManager.initialize(context, prefs, profileId)
|
||||
CookieManager.useStore(profileId)
|
||||
_themePreferenceFlow.value = state.themePreference
|
||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||
_themePresetFlow.value = state.themePreset
|
||||
_chatFontScaleFlow.value = state.chatFontScale
|
||||
scope.launch {
|
||||
store.stateFlow.collect { latest ->
|
||||
_themePreferenceFlow.value = latest.themePreference
|
||||
_useDynamicColorsFlow.value = latest.useDynamicColors
|
||||
_themePresetFlow.value = latest.themePreset
|
||||
_chatFontScaleFlow.value = latest.chatFontScale
|
||||
syncCookieStoreForProfile(latest.selectedProfileId)
|
||||
}
|
||||
}
|
||||
_initializationState.value = InitializationState.Ready
|
||||
} catch (e: CancellationException) {
|
||||
prefs.cancel(e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
prefs.completeExceptionally(e)
|
||||
_initializationState.value = InitializationState.Failed
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the encrypted cookie store (issue #470). The legacy
|
||||
// session-cookie prefs are passed as a Deferred so existing gated
|
||||
// sessions can be migrated on first load WITHOUT blocking startup.
|
||||
// The Deferred is created above, before this call.
|
||||
val initialProfileId =
|
||||
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
CookieManager.initialize(context, prefsDeferred, initialProfileId)
|
||||
|
||||
scope.launch {
|
||||
store.stateFlow.collect { state ->
|
||||
_themePreferenceFlow.value = state.themePreference
|
||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||
_themePresetFlow.value = state.themePreset
|
||||
_chatFontScaleFlow.value = state.chatFontScale
|
||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the initialized [SharedPreferences] instance.
|
||||
*
|
||||
* WARNING: This method is synchronous and will block the caller thread (using [runBlocking])
|
||||
* if the asynchronous initialization is still in progress. Callers should avoid invoking this
|
||||
* on the main thread during early startup to prevent frame drops or potential ANRs.
|
||||
*
|
||||
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds.
|
||||
*/
|
||||
/** Suspend without blocking the caller or imposing a keystore startup deadline. */
|
||||
suspend fun awaitInitialization() {
|
||||
checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
|
||||
}
|
||||
|
||||
internal suspend fun awaitPrefs(): SharedPreferences {
|
||||
awaitInitialization()
|
||||
return requirePrefs()
|
||||
}
|
||||
|
||||
/** Synchronous APIs are ready-only; entry points must await initialization first. */
|
||||
private fun requirePrefs(): SharedPreferences =
|
||||
runBlocking {
|
||||
val deferred =
|
||||
prefsDeferred ?: throw IllegalStateException(
|
||||
"AuthManager not initialized. Call init(context) first.",
|
||||
)
|
||||
kotlinx.coroutines.withTimeoutOrNull(2000) {
|
||||
deferred.await()
|
||||
} ?: throw IllegalStateException("AuthManager initialization timed out after 2 seconds.")
|
||||
}
|
||||
checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
|
||||
|
||||
fun setWsAuthParam(param: String) {
|
||||
serverStore.update { it.copy(wsAuthParam = param) }
|
||||
@@ -259,18 +316,34 @@ object AuthManager {
|
||||
|
||||
// ── Database Master Password ─────────────────────────────────────────
|
||||
|
||||
fun getDatabasePassword(): ByteArray {
|
||||
val prefs = requirePrefs()
|
||||
var dbPasswordBase64 = prefs.getString("db_password", null)
|
||||
if (dbPasswordBase64 == null) {
|
||||
val random = java.security.SecureRandom()
|
||||
val newPassword = ByteArray(32)
|
||||
random.nextBytes(newPassword)
|
||||
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP)
|
||||
prefs.edit().putString("db_password", dbPasswordBase64).apply()
|
||||
suspend fun getDatabasePassword(): ByteArray =
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = awaitPrefs()
|
||||
synchronized(this@AuthManager) {
|
||||
cachedDatabasePassword?.let { return@synchronized it.copyOf() }
|
||||
val stored = prefs.getString("db_password", null)
|
||||
val password =
|
||||
if (stored == null) {
|
||||
ByteArray(32).also { bytes ->
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
val encoded =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(bytes)
|
||||
// Never create a database using a key that was not durably saved.
|
||||
check(prefs.edit().putString("db_password", encoded).commit()) {
|
||||
"Unable to persist database key"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
java.util.Base64
|
||||
.getDecoder()
|
||||
.decode(stored)
|
||||
}
|
||||
cachedDatabasePassword = password
|
||||
password.copyOf()
|
||||
}
|
||||
}
|
||||
return android.util.Base64.decode(dbPasswordBase64, android.util.Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
// ── Connection Profiles ──────────────────────────────────────────────
|
||||
|
||||
@@ -387,15 +460,15 @@ object AuthManager {
|
||||
token: String?,
|
||||
) {
|
||||
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||
if (getSelectedProfileId() == profileId) {
|
||||
if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
|
||||
if (token == null) ActiveSessionHolder.clear()
|
||||
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
|
||||
// to prevent stale tokens during ticket refresh
|
||||
synchronized(this) {
|
||||
cachedToken = token
|
||||
cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
|
||||
tokenInitialized = true
|
||||
_tokenFlow.value = cachedToken
|
||||
}
|
||||
_tokenFlow.value = token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +512,9 @@ object AuthManager {
|
||||
*/
|
||||
fun setActiveProfileId(id: String?) {
|
||||
val normalized = id?.takeIf { it.isNotBlank() }
|
||||
// Flow first, persistence best-effort: a caller racing cold startup
|
||||
// still gets the correct in-memory scope instead of an exception, and
|
||||
// the write lands once initialization is Ready (#1171).
|
||||
_activeProfileId.value = normalized
|
||||
runCatching {
|
||||
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply()
|
||||
@@ -482,6 +558,13 @@ object AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun resetAndAwaitForTest() {
|
||||
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
|
||||
resetAuthStateForTest()
|
||||
previousJob?.join()
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
fun resetAuthStateForTest() {
|
||||
synchronized(this) {
|
||||
@@ -489,6 +572,14 @@ object AuthManager {
|
||||
tokenInitialized = false
|
||||
_serverStore = null
|
||||
prefsDeferred = null
|
||||
initialization?.cancel()
|
||||
initialization = null
|
||||
readyPrefs = null
|
||||
cachedDatabasePassword?.fill(0)
|
||||
cachedDatabasePassword = null
|
||||
_initializationState.value = InitializationState.Loading
|
||||
_tokenFlow.value = null
|
||||
_selectedProfileFlow.value = null
|
||||
appScope?.let {
|
||||
try {
|
||||
it.cancel()
|
||||
@@ -539,6 +630,9 @@ object AuthManager {
|
||||
}
|
||||
|
||||
fun setToken(token: String?) {
|
||||
if (token == null) {
|
||||
invalidateAuthGeneration()
|
||||
}
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
@@ -555,6 +649,23 @@ object AuthManager {
|
||||
|
||||
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
|
||||
|
||||
fun currentDataScope(): DataScope? {
|
||||
val rawUrl = runCatching { getBaseUrl() }.getOrNull() ?: _baseUrlFlow.value
|
||||
val cleanUrl = rawUrl.trimEnd('/')
|
||||
if (cleanUrl.isBlank()) return null
|
||||
val connectionProfileId =
|
||||
runCatching { getSelectedProfileId() }.getOrNull()
|
||||
?: _selectedProfileFlow.value
|
||||
?: DEFAULT_PROFILE_ID
|
||||
val activeProfile = _activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
return DataScope(
|
||||
connectionProfileId = connectionProfileId,
|
||||
baseUrl = cleanUrl,
|
||||
activeProfileId = activeProfile,
|
||||
inMemoryAuthGeneration = _authGenerationFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
fun endpoint(): ServerEndpoint =
|
||||
ServerEndpoint.parse(
|
||||
getBaseUrl(),
|
||||
@@ -739,6 +850,18 @@ object AuthManager {
|
||||
serverStore.update { it.copy(showTokensPerSecond = enabled) }
|
||||
}
|
||||
|
||||
fun isModelProviderShown(): Boolean = serverStore.getLatestState().showModelProvider
|
||||
|
||||
fun setModelProviderShown(shown: Boolean) {
|
||||
serverStore.update { it.copy(showModelProvider = shown) }
|
||||
}
|
||||
|
||||
fun isKeepConnectedInBackground(): Boolean = serverStore.getLatestState().keepConnectedInBackground
|
||||
|
||||
fun setKeepConnectedInBackground(enabled: Boolean) {
|
||||
serverStore.update { it.copy(keepConnectedInBackground = enabled) }
|
||||
}
|
||||
|
||||
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||
|
||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||
@@ -750,6 +873,16 @@ object AuthManager {
|
||||
|
||||
// ── In-app update check (issue #867) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the user opted into release-candidate updates. Stable-only by
|
||||
* default; when true the update check also considers pre-release RC tags.
|
||||
*/
|
||||
fun isCheckingReleaseCandidateUpdates(): Boolean = serverStore.getLatestState().checkReleaseCandidateUpdates
|
||||
|
||||
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
|
||||
serverStore.update { it.copy(checkReleaseCandidateUpdates = enabled) }
|
||||
}
|
||||
|
||||
/** App version the silent update check last completed for (null = never). */
|
||||
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
|
||||
|
||||
|
||||
@@ -46,4 +46,6 @@ data class ChatMessageEntity(
|
||||
val tokenCount: Int? = null,
|
||||
@ColumnInfo(name = "tps")
|
||||
val tps: Double? = null,
|
||||
@ColumnInfo(name = "completion_id")
|
||||
val completionId: String? = null,
|
||||
)
|
||||
@@ -37,6 +37,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
@@ -54,4 +55,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
/**
|
||||
* Canonical data-scope identity representing the authenticated server and profile context.
|
||||
* Used to partition caches (both in-memory and persistent) so that switching servers,
|
||||
* changing server URLs, switching server-side profiles, or logging out/re-logging in
|
||||
* never leaks data across boundaries.
|
||||
*
|
||||
* Contains no credentials, tokens, cookies, passwords, or secrets.
|
||||
*/
|
||||
data class DataScope(
|
||||
val connectionProfileId: String,
|
||||
val baseUrl: String,
|
||||
val activeProfileId: String,
|
||||
val inMemoryAuthGeneration: Long = 0L,
|
||||
) {
|
||||
/**
|
||||
* Deterministic in-memory cache key.
|
||||
* Uses length-prefixing for unambiguous separation of components, and includes
|
||||
* [inMemoryAuthGeneration] so logging out and re-logging in automatically invalidates
|
||||
* all in-memory caches.
|
||||
*/
|
||||
fun inMemoryKey(localKey: String = "default"): String = "g$inMemoryAuthGeneration:${persistentKey(localKey)}"
|
||||
|
||||
/**
|
||||
* Deterministic persistent cache key for disk storage (e.g. SessionListCacheStore).
|
||||
* Uses length-prefixing so no delimiter collision is possible.
|
||||
* Does NOT include volatile [inMemoryAuthGeneration] so disk caches survive normal process restarts.
|
||||
* Disk caches are explicitly wiped on logout.
|
||||
*/
|
||||
fun persistentKey(localKey: String = "default"): String =
|
||||
"c${connectionProfileId.length}:$connectionProfileId:u${baseUrl.length}:$baseUrl:p${activeProfileId.length}:$activeProfileId:k${localKey.length}:$localKey"
|
||||
|
||||
fun scopedKey(localKey: String = "default"): String = inMemoryKey(localKey)
|
||||
|
||||
companion object {
|
||||
val EMPTY =
|
||||
DataScope(
|
||||
connectionProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
baseUrl = "",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
inMemoryAuthGeneration = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import androidx.room3.RoomDatabase
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
|
||||
import java.io.File
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
@@ -79,40 +81,55 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): HermesDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
|
||||
// create an encrypted replacement from scratch.
|
||||
val dbFile = context.getDatabasePath("hermes_control.db")
|
||||
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
|
||||
dbFile.delete()
|
||||
}
|
||||
|
||||
// Load SQLCipher native library before creating the driver
|
||||
System.loadLibrary("sqlcipher")
|
||||
val driver =
|
||||
SQLCipherDriver(
|
||||
AuthManager.getDatabasePassword(),
|
||||
null,
|
||||
null,
|
||||
val MIGRATION_7_8: Migration =
|
||||
object : Migration(7, 8) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).setDriver(driver)
|
||||
.addMigrations(
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
suspend fun get(context: Context): HermesDatabase =
|
||||
withContext(Dispatchers.IO) {
|
||||
instance?.let { return@withContext it }
|
||||
val password = AuthManager.getDatabasePassword()
|
||||
synchronized(this@Companion) {
|
||||
instance?.let { return@synchronized it }
|
||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
|
||||
// create an encrypted replacement from scratch.
|
||||
val dbFile = context.getDatabasePath("hermes_control.db")
|
||||
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
|
||||
dbFile.delete()
|
||||
}
|
||||
|
||||
// Load SQLCipher native library before creating the driver
|
||||
System.loadLibrary("sqlcipher")
|
||||
val driver =
|
||||
SQLCipherDriver(
|
||||
password,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).setDriver(driver)
|
||||
.addMigrations(
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the database file starts with the SQLCipher magic header. */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
|
||||
/**
|
||||
* Lightweight disk cache for the initial page of sessions.
|
||||
* Persists the first page JSON to SharedPreferences so the History screen
|
||||
* can render instantly even after app kill / process death.
|
||||
*/
|
||||
object SessionListCacheStore {
|
||||
private const val PREFS_NAME = "hermes_sessions_cache"
|
||||
private const val KEY_PREFIX = "sessions_page_"
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (prefs == null) {
|
||||
synchronized(this) {
|
||||
if (prefs == null) {
|
||||
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(key: String): SessionListResponse? {
|
||||
val p = prefs ?: return null
|
||||
val raw = p.getString(KEY_PREFIX + key, null) ?: return null
|
||||
return try {
|
||||
OkHttpProvider.json.decodeFromString<SessionListResponse>(raw)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun put(
|
||||
key: String,
|
||||
data: SessionListResponse,
|
||||
) {
|
||||
val p = prefs ?: return
|
||||
try {
|
||||
val json = OkHttpProvider.json.encodeToString(SessionListResponse.serializer(), data)
|
||||
p.edit().putString(KEY_PREFIX + key, json).apply()
|
||||
} catch (_: Exception) {
|
||||
// Ignore serialization issues on cache save
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs?.edit()?.remove(KEY_PREFIX + key)?.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun resetForTesting(testPrefs: SharedPreferences? = null) {
|
||||
synchronized(this) {
|
||||
prefs = testPrefs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
/**
|
||||
* Lightweight thread-safe in-memory cache for Stale-While-Revalidate screen state.
|
||||
* Stores entries with optional TTL and a bounded capacity.
|
||||
*
|
||||
* All map operations are synchronized on a private lock so that compound
|
||||
* operations (e.g. read + TTL expiry check + remove) are atomic and safe against races.
|
||||
*/
|
||||
class SwrCache<K : Any, V : Any>(
|
||||
private val maxCapacity: Int = DEFAULT_MAX_CAPACITY,
|
||||
private val ttlMillis: Long = DEFAULT_TTL_MILLIS,
|
||||
private val timeProvider: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private data class Entry<V>(
|
||||
val value: V,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val map =
|
||||
object : LinkedHashMap<K, Entry<V>>(maxCapacity, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, Entry<V>>?): Boolean = size > maxCapacity
|
||||
}
|
||||
|
||||
fun get(key: K): V? =
|
||||
synchronized(lock) {
|
||||
val entry = map[key] ?: return null
|
||||
if (ttlMillis > 0 && timeProvider() - entry.timestamp > ttlMillis) {
|
||||
map.remove(key)
|
||||
return null
|
||||
}
|
||||
entry.value
|
||||
}
|
||||
|
||||
fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
map[key] = Entry(value, timeProvider())
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: K): V? =
|
||||
synchronized(lock) {
|
||||
map.remove(key)?.value
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
map.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val size: Int
|
||||
get() = synchronized(lock) { map.size }
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_CAPACITY = 50
|
||||
const val DEFAULT_TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.MediaKind
|
||||
import com.m57.hermescontrol.ui.chat.classifyMedia
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -25,22 +27,23 @@ data class Attachment(
|
||||
val gatewayUrl: String? = null,
|
||||
val source: AttachmentSource = AttachmentSource.LOCAL,
|
||||
) {
|
||||
val mediaKind: MediaKind
|
||||
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
|
||||
|
||||
val isImage: Boolean
|
||||
get() = mimeType.startsWith("image/")
|
||||
get() = mediaKind == MediaKind.IMAGE
|
||||
|
||||
val isGif: Boolean
|
||||
get() =
|
||||
mimeType.equals("image/gif", ignoreCase = true) ||
|
||||
fileExtension == "gif" ||
|
||||
uri.contains(".gif", ignoreCase = true) ||
|
||||
uri.startsWith("data:image/gif", ignoreCase = true)
|
||||
|
||||
val isVideo: Boolean
|
||||
get() =
|
||||
mimeType.startsWith("video/") ||
|
||||
fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") ||
|
||||
uri.contains(".mp4", ignoreCase = true) ||
|
||||
uri.contains(".webm", ignoreCase = true)
|
||||
get() = mediaKind == MediaKind.VIDEO
|
||||
|
||||
val isAudio: Boolean
|
||||
get() = mediaKind == MediaKind.AUDIO
|
||||
|
||||
val isGateway: Boolean
|
||||
get() = source == AttachmentSource.GATEWAY
|
||||
|
||||
@@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class SessionSearchResponse(
|
||||
val results: List<SessionSearchResult> = emptyList(),
|
||||
)
|
||||
val has_more: Boolean? = null,
|
||||
val next_offset: Int? = null,
|
||||
) {
|
||||
val hasMore: Boolean get() = has_more == true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SessionSearchResult(
|
||||
@@ -15,4 +19,9 @@ data class SessionSearchResult(
|
||||
val source: String? = null,
|
||||
val model: String? = null,
|
||||
val session_started: Double? = null,
|
||||
val title: String? = null,
|
||||
val last_active: Double? = null,
|
||||
val message_count: Int? = null,
|
||||
val archived: Boolean? = null,
|
||||
val lineage_root: String? = null,
|
||||
)
|
||||
+61
-24
@@ -2,26 +2,64 @@ package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
|
||||
internal object DashboardSessionTokenRefresher {
|
||||
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
// Synchronized so concurrent 401s / reconnect storms don't each fire a
|
||||
// redundant GET at the dashboard SPA (thundering-herd guard).
|
||||
@Synchronized
|
||||
fun refresh(): String? =
|
||||
try {
|
||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||
AuthManager.setToken(token)
|
||||
token
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
internal fun fetch(
|
||||
suspend fun refreshAsync(): String? =
|
||||
refreshMutex.withLock {
|
||||
try {
|
||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
||||
AuthManager.setToken(token)
|
||||
token
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun fetch(
|
||||
baseUrl: String,
|
||||
client: OkHttpClient,
|
||||
): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(baseUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).await().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
extractToken(response)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun fetchSync(
|
||||
baseUrl: String,
|
||||
client: OkHttpClient,
|
||||
): String? =
|
||||
@@ -32,32 +70,31 @@ internal object DashboardSessionTokenRefresher {
|
||||
.url(baseUrl)
|
||||
.get()
|
||||
.build()
|
||||
// execute() + body read on Dispatchers.IO: the body read touches
|
||||
// the socket on the CALLING thread otherwise — when that is main
|
||||
// (loopback ticket mint from the kanban events connect), it throws
|
||||
// NetworkOnMainThreadException (same bug class as requestWsTicket).
|
||||
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@runBlocking null
|
||||
// Cap the read — the SPA HTML can be large, and we only need the
|
||||
// injected token near the top of the document. Reading the whole
|
||||
// body into a String risks OOM on low-end devices.
|
||||
val html =
|
||||
response.body.source().use { source ->
|
||||
val buffer = Buffer()
|
||||
source.read(buffer, MAX_BODY_BYTES)
|
||||
buffer.readUtf8()
|
||||
}
|
||||
tokenPattern
|
||||
.find(html)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
extractToken(response)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun extractToken(response: Response): String? {
|
||||
// Cap the read — the SPA HTML can be large, and we only need the
|
||||
// injected token near the top of the document. Reading the whole
|
||||
// body into a String risks OOM on low-end devices.
|
||||
val html =
|
||||
response.body.source().use { source ->
|
||||
val buffer = Buffer()
|
||||
source.read(buffer, MAX_BODY_BYTES)
|
||||
buffer.readUtf8()
|
||||
}
|
||||
return tokenPattern
|
||||
.find(html)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private const val MAX_BODY_BYTES: Long = 64 * 1024
|
||||
}
|
||||
@@ -277,6 +277,7 @@ object GatewayFileClient {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
coroutineContext.ensureActive()
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,8 @@ interface HermesApiService : KanbanApiService {
|
||||
@Query("profile") profile: String? = null,
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<SessionSearchResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
object MediaDataSourceProvider {
|
||||
class SameOriginAuthInterceptor(
|
||||
private val baseUrlProvider: () -> String = { runCatching { AuthManager.baseUrl() }.getOrDefault("") },
|
||||
private val tokenProvider: () -> String? = { runCatching { AuthManager.getToken() }.getOrNull() },
|
||||
private val isGatedModeProvider: () -> Boolean = {
|
||||
runCatching { AuthManager.isGatedMode() }.getOrDefault(
|
||||
false,
|
||||
)
|
||||
},
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
// In gated mode (basic auth), the dashboard authenticates via session cookies;
|
||||
// stamping an Authorization: Bearer header causes 401s (issue #470).
|
||||
if (isGatedModeProvider()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url = request.url
|
||||
val base = baseUrlProvider().toHttpUrlOrNull()
|
||||
val isSameOrigin =
|
||||
base != null &&
|
||||
url.scheme.equals(base.scheme, ignoreCase = true) &&
|
||||
url.host.equals(base.host, ignoreCase = true) &&
|
||||
url.port == base.port
|
||||
|
||||
if (isSameOrigin) {
|
||||
val token = tokenProvider()
|
||||
if (!token.isNullOrBlank() && request.header("Authorization") == null) {
|
||||
return chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
internal var testClient: OkHttpClient? = null
|
||||
|
||||
val mediaOkHttpClient: OkHttpClient
|
||||
get() = testClient ?: defaultClient
|
||||
|
||||
private val defaultClient: OkHttpClient by lazy {
|
||||
OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.addInterceptor(SameOriginAuthInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun createDataSourceFactory(context: Context): DataSource.Factory {
|
||||
val httpDataSourceFactory = OkHttpDataSource.Factory(mediaOkHttpClient)
|
||||
return DefaultDataSource.Factory(context, httpDataSourceFactory)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -19,6 +22,10 @@ object NetworkMonitor {
|
||||
private val networkChangeEvents = MutableSharedFlow<Boolean>(extraBufferCapacity = Int.MAX_VALUE)
|
||||
internal val networkChanges: SharedFlow<Boolean> = networkChangeEvents.asSharedFlow()
|
||||
|
||||
// Emitted when default network transports change without onLost/onAvailable (issue #1165).
|
||||
private val transportChangeEvents = MutableSharedFlow<Unit>(extraBufferCapacity = Int.MAX_VALUE)
|
||||
internal val transportChanges: SharedFlow<Unit> = transportChangeEvents.asSharedFlow()
|
||||
|
||||
private val callbackLock = Any()
|
||||
private val callbackGeneration = AtomicInteger(0)
|
||||
private var callbackRegistration: Pair<ConnectivityManager, ConnectivityManager.NetworkCallback>? = null
|
||||
@@ -26,6 +33,9 @@ object NetworkMonitor {
|
||||
@Volatile
|
||||
private var defaultNetwork: Network? = null
|
||||
|
||||
@Volatile
|
||||
private var currentTransports: Set<Int>? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = createCallback(cm)
|
||||
@@ -38,6 +48,10 @@ object NetworkMonitor {
|
||||
defaultNetwork = activeNetwork
|
||||
networkAvailable = activeNetwork != null
|
||||
_isConnected.value = networkAvailable
|
||||
currentTransports =
|
||||
runCatching {
|
||||
activeNetwork?.let { cm.getNetworkCapabilities(it) }?.let { extractTransports(it) }
|
||||
}.getOrNull()
|
||||
callbackRegistration.also { callbackRegistration = cm to callback }
|
||||
}
|
||||
if (networkChanged) networkChangeEvents.tryEmit(networkAvailable)
|
||||
@@ -56,6 +70,7 @@ object NetworkMonitor {
|
||||
if (callbackGeneration.get() != generation) return
|
||||
(defaultNetwork != network).also {
|
||||
defaultNetwork = network
|
||||
currentTransports = null
|
||||
_isConnected.value = true
|
||||
}
|
||||
}
|
||||
@@ -71,11 +86,86 @@ object NetworkMonitor {
|
||||
(defaultNetwork != activeNetwork).also {
|
||||
defaultNetwork = activeNetwork
|
||||
networkAvailable = activeNetwork != null
|
||||
currentTransports = null
|
||||
_isConnected.value = networkAvailable
|
||||
}
|
||||
}
|
||||
if (changed) networkChangeEvents.tryEmit(networkAvailable)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
val transportChanged: Boolean
|
||||
synchronized(callbackLock) {
|
||||
if (callbackGeneration.get() != generation || defaultNetwork != network) return
|
||||
val newTransports = extractTransports(networkCapabilities)
|
||||
val oldTransports = currentTransports
|
||||
currentTransports = newTransports
|
||||
transportChanged = oldTransports != null && oldTransports != newTransports
|
||||
}
|
||||
if (transportChanged) {
|
||||
transportChangeEvents.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLosing(
|
||||
network: Network,
|
||||
maxMsToLive: Int,
|
||||
) {
|
||||
val isDefault: Boolean
|
||||
synchronized(callbackLock) {
|
||||
if (callbackGeneration.get() != generation || defaultNetwork != network) return
|
||||
isDefault = true
|
||||
}
|
||||
if (isDefault) {
|
||||
transportChangeEvents.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun extractTransports(capabilities: NetworkCapabilities?): Set<Int> {
|
||||
if (capabilities == null) return emptySet()
|
||||
val transports = mutableSetOf<Int>()
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_BLUETOOTH)
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_LOWPAN)
|
||||
) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_LOWPAN)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB)
|
||||
) {
|
||||
transports.add(NetworkCapabilities.TRANSPORT_USB)
|
||||
}
|
||||
return transports
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun resetForTest() {
|
||||
synchronized(callbackLock) {
|
||||
defaultNetwork = null
|
||||
currentTransports = null
|
||||
_isConnected.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
|
||||
}
|
||||
val backoff = jitteredBackoff(500L * (1 shl attempt))
|
||||
delay(backoff)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
return NetworkResult.Failure(
|
||||
NetworkError.Unknown(
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -31,6 +32,18 @@ import kotlinx.coroutines.withContext
|
||||
* 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
|
||||
*/
|
||||
object ProfileSwitchCoordinator {
|
||||
/**
|
||||
* Dispatcher for the blocking network hops below.
|
||||
*
|
||||
* Injectable so tests can drive the whole switch on a TestDispatcher. With the
|
||||
* real Dispatchers.IO these paths hop to a live thread pool and the ORDER of
|
||||
* the mocked calls becomes load-dependent -- ProfileSwitchCoordinatorTest's
|
||||
* Ordering.SEQUENCE checks passed on an idle machine but lost 2 tests while the
|
||||
* emulator saturated the CPU, and its setMain-less sibling tests failed
|
||||
* outright whenever another class had left Dispatchers.Main broken.
|
||||
*/
|
||||
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val switched: SharedFlow<String> = _switched.asSharedFlow()
|
||||
|
||||
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
|
||||
|
||||
suspend fun switchProfile(name: String): NetworkResult<Unit> {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
|
||||
}
|
||||
if (result !is NetworkResult.Success) return result
|
||||
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
|
||||
// run off the main thread or the dial crashes with
|
||||
// NetworkOnMainThreadException and falls back to the 1s reconnect
|
||||
// retry (visible in the 2026-08-06 live logcat).
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
|
||||
AuthManager.setSelectedProfileId(profileId)
|
||||
ApiClient.rebuild()
|
||||
_connectionSwitched.emit(profileId.orEmpty())
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
// The WS ticket mint reads the cookie jar's ACTIVE store; the
|
||||
// selection change swaps that store asynchronously, so a dial
|
||||
// that races it mints with the PREVIOUS server's cookie → 401 →
|
||||
|
||||
@@ -18,6 +18,9 @@ import java.io.IOException
|
||||
data class UpdateInfo(
|
||||
val tagName: String = "",
|
||||
val body: String = "",
|
||||
/** GitHub release flags: a draft is unpublished, a prerelease is pre-stable. */
|
||||
val draft: Boolean = false,
|
||||
val prerelease: Boolean = false,
|
||||
val assets: List<Asset> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
@@ -36,33 +39,105 @@ data class UpdateInfo(
|
||||
fun normalizedVersion(tag: String): String = tag.trim().removePrefix("v")
|
||||
|
||||
/**
|
||||
* True when [latest] is strictly newer than [current]. Numeric dot-segment
|
||||
* comparison ("1.21.0" > "1.2"). Non-numeric suffixes (e.g. "1.0-dev")
|
||||
* compare as 0; an unparseable version never claims an update exists.
|
||||
* True when [latest] is strictly newer than [current] (issue #867).
|
||||
* Compare the numeric core first, padding missing parts with zero, then
|
||||
* prerelease identifiers. Stable outranks prerelease for the same core.
|
||||
* Accept both "1.25.0-rc.1" and legacy "1.25.rc.1"; ignore build metadata.
|
||||
* Invalid versions never claim an update exists.
|
||||
*/
|
||||
fun isNewerVersion(
|
||||
latest: String,
|
||||
current: String,
|
||||
): Boolean {
|
||||
val a = versionSegments(latest) ?: return false
|
||||
val b = versionSegments(current) ?: return false
|
||||
for (i in 0 until maxOf(a.size, b.size)) {
|
||||
val x = a.getOrElse(i) { 0 }
|
||||
val y = b.getOrElse(i) { 0 }
|
||||
if (x != y) return x > y
|
||||
val a = parseVersion(latest) ?: return false
|
||||
val b = parseVersion(current) ?: return false
|
||||
for (i in 0 until maxOf(a.core.size, b.core.size)) {
|
||||
val comparison = a.core.getOrElse(i) { 0 }.compareTo(b.core.getOrElse(i) { 0 })
|
||||
if (comparison != 0) return comparison > 0
|
||||
}
|
||||
return false
|
||||
if (a.prerelease.isEmpty()) return b.prerelease.isNotEmpty()
|
||||
if (b.prerelease.isEmpty()) return false
|
||||
for (i in 0 until minOf(a.prerelease.size, b.prerelease.size)) {
|
||||
val comparison = comparePrereleaseIdentifier(a.prerelease[i], b.prerelease[i])
|
||||
if (comparison != 0) return comparison > 0
|
||||
}
|
||||
return a.prerelease.size > b.prerelease.size
|
||||
}
|
||||
|
||||
private fun versionSegments(version: String): List<Int>? {
|
||||
val cleaned = normalizedVersion(version)
|
||||
if (cleaned.isBlank()) return null
|
||||
return cleaned.split('.').map { segment ->
|
||||
// Leading numeric portion of each segment: "0-dev" → 0, "21" → 21.
|
||||
segment.takeWhile { it.isDigit() }.toIntOrNull() ?: 0
|
||||
}
|
||||
/**
|
||||
* Pick the newest installable release out of a GitHub `releases` list payload.
|
||||
*
|
||||
* Drafts and releases without an APK asset are always ignored. Pre-releases are
|
||||
* only eligible when [includeReleaseCandidates] is set *and* the tag is a
|
||||
* release candidate — alpha/beta/dev pre-releases stay out of the RC channel.
|
||||
*
|
||||
* Selection uses [isNewerVersion] rather than list order because the API orders
|
||||
* releases by publication date, which is not a version oracle.
|
||||
*/
|
||||
fun selectLatestUpdate(
|
||||
releases: List<UpdateInfo>,
|
||||
includeReleaseCandidates: Boolean = false,
|
||||
): UpdateInfo? =
|
||||
releases
|
||||
.filter { !it.draft && it.apkAsset != null }
|
||||
.filter {
|
||||
if (!it.prerelease) {
|
||||
true
|
||||
} else {
|
||||
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
|
||||
}
|
||||
}.reduceOrNull { best, candidate ->
|
||||
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
|
||||
}
|
||||
|
||||
private data class ParsedVersion(
|
||||
val core: List<Int>,
|
||||
val prerelease: List<String>,
|
||||
)
|
||||
|
||||
private val VERSION_PATTERN =
|
||||
Regex(
|
||||
"""^([0-9]+(?:\.[0-9]+)*)(?:(?:-|\.(?=[a-zA-Z]))([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)?$""",
|
||||
)
|
||||
|
||||
private fun parseVersion(version: String): ParsedVersion? {
|
||||
val match = VERSION_PATTERN.matchEntire(normalizedVersion(version)) ?: return null
|
||||
val core = match.groupValues[1].split('.').map { it.toIntOrNull() ?: return null }
|
||||
val prerelease =
|
||||
match.groupValues[2]
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.split('.')
|
||||
.orEmpty()
|
||||
return ParsedVersion(core, prerelease)
|
||||
}
|
||||
|
||||
private fun comparePrereleaseIdentifier(
|
||||
a: String,
|
||||
b: String,
|
||||
): Int {
|
||||
val aNumeric = a.all { it in '0'..'9' }
|
||||
val bNumeric = b.all { it in '0'..'9' }
|
||||
if (aNumeric != bNumeric) return if (aNumeric) -1 else 1
|
||||
if (!aNumeric) return a.compareTo(b)
|
||||
// Compare arbitrarily large RC numbers without integer overflow.
|
||||
val x = a.trimStart('0')
|
||||
val y = b.trimStart('0')
|
||||
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
|
||||
}
|
||||
|
||||
/** `rc`, `rc1`, `RC2` — but never alpha/beta/dev. */
|
||||
private val RC_IDENTIFIER = Regex("^rc[0-9]*$", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* True when [tag] is a release-candidate version such as "v1.25.0-rc.1",
|
||||
* "v1.25-rc1", or the legacy dot form "v1.25.rc.1".
|
||||
*
|
||||
* Only the first pre-release identifier is inspected, so alpha/beta builds are
|
||||
* never treated as release candidates.
|
||||
*/
|
||||
fun isReleaseCandidateVersion(tag: String): Boolean =
|
||||
parseVersion(tag)?.prerelease?.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
|
||||
|
||||
/**
|
||||
* Talks to the GitHub releases API and downloads the release APK (issue
|
||||
* #867). Network methods are `open` so tests can fake them; the pure logic
|
||||
@@ -71,27 +146,44 @@ private fun versionSegments(version: String): List<Int>? {
|
||||
*/
|
||||
open class AppUpdateChecker(
|
||||
private val client: OkHttpClient = OkHttpProvider.base,
|
||||
private val apiBaseUrl: String = "https://api.github.com/repos/Hy4ri/hermes-mobile",
|
||||
) {
|
||||
/**
|
||||
* Fetch the latest release metadata. Returns null when there is no
|
||||
* release yet (404) or the body can't be parsed. Throws [IOException]
|
||||
* on network failure so the caller can surface a friendly error.
|
||||
* Fetch the newest installable release metadata.
|
||||
*
|
||||
* Stable-only by default: the `releases/latest` endpoint deliberately
|
||||
* excludes pre-releases. With [includeReleaseCandidates] the releases list
|
||||
* is scanned instead so a newer release-candidate tag becomes eligible.
|
||||
*
|
||||
* Returns null when there is no usable release (404) or the body can't be
|
||||
* parsed. Throws [IOException] on network failure so the caller can
|
||||
* surface a friendly error.
|
||||
*/
|
||||
open suspend fun fetchLatestRelease(): UpdateInfo? =
|
||||
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
val body = response.body.string().orEmpty()
|
||||
if (includeReleaseCandidates) {
|
||||
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
|
||||
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
|
||||
} else {
|
||||
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
|
||||
parseUpdateInfo(body)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET [url] as JSON text, or null on any non-2xx response. */
|
||||
private fun get(url: String): String? {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
return response.body.string().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||
* [onProgress]. True on success; false (with the partial file deleted)
|
||||
@@ -134,6 +226,9 @@ open class AppUpdateChecker(
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_BUFFER_SIZE = 8192
|
||||
|
||||
/** How many recent releases the opt-in RC scan inspects. */
|
||||
const val RELEASE_LIST_PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +237,9 @@ fun parseUpdateInfo(json: String): UpdateInfo? =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
|
||||
}.getOrNull()
|
||||
|
||||
/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */
|
||||
fun parseReleaseList(json: String): List<UpdateInfo>? =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<List<UpdateInfo>>(json)
|
||||
}.getOrNull()
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.update
|
||||
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
|
||||
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
||||
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
|
||||
|
||||
// Captured once so the check and the notice path agree on the channel.
|
||||
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||
val result =
|
||||
try {
|
||||
checker.fetchLatestRelease()
|
||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
val info = result ?: return@launch
|
||||
// The user may have switched back to stable while this request was in
|
||||
// flight — an RC result must never be published on the stable channel.
|
||||
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return@launch
|
||||
}
|
||||
val apk = info.apkAsset ?: return@launch
|
||||
val state =
|
||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
|
||||
|
||||
/**
|
||||
* The tag the chat banner should advertise, or null when nothing newer is
|
||||
* known or if the user explicitly dismissed this tag.
|
||||
* known, if the user explicitly dismissed this tag, or if it is a
|
||||
* release-candidate tag while the user is on the stable channel.
|
||||
*/
|
||||
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||
if (!enabled) return null
|
||||
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
|
||||
?: return null
|
||||
|
||||
if (candidateTag == dismissed) return null
|
||||
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return null
|
||||
}
|
||||
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
||||
@@ -38,8 +39,22 @@ object EventParser {
|
||||
}
|
||||
|
||||
// ── Notification / event (no id, has method) ─────────────────────
|
||||
val jsonParams = response.params ?: return WsEvent.Unknown(rawJson)
|
||||
// Issue #1163: token events need only scalar lookups, never recursive map/list copies.
|
||||
val eventType = jsonParams["type"].eventStringOrNull()
|
||||
if (eventType == "message.token" || eventType == "message.delta" ||
|
||||
eventType == "thinking.delta" || eventType == "reasoning.delta"
|
||||
) {
|
||||
val sessionId = jsonParams.eventSessionId()
|
||||
val token = (jsonParams["payload"] as? JsonObject)?.get("text").eventStringOrNull() ?: ""
|
||||
return when (eventType) {
|
||||
"thinking.delta" -> WsEvent.ThinkingDelta(token, sessionId)
|
||||
"reasoning.delta" -> WsEvent.ReasoningDelta(token, sessionId)
|
||||
else -> WsEvent.MessageToken(token, sessionId)
|
||||
}
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
|
||||
val params = jsonParams.toAny() as Map<String, Any?>
|
||||
return parseParams(params, rawJson)
|
||||
}
|
||||
|
||||
@@ -95,7 +110,18 @@ object EventParser {
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
val reasoning = payload?.get("reasoning") as? String
|
||||
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
|
||||
val completionId =
|
||||
payload?.get("completion_id") as? String
|
||||
?: java.util.UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
WsEvent.MessageComplete(
|
||||
text,
|
||||
sessionId,
|
||||
reasoning,
|
||||
rawPayload = payload,
|
||||
completionId = completionId,
|
||||
)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
@@ -113,14 +139,16 @@ object EventParser {
|
||||
}
|
||||
|
||||
"tool.progress" -> {
|
||||
val toolId = payload?.get("tool_id") as? String
|
||||
val name = payload?.get("name") as? String
|
||||
val preview = payload?.get("preview") as? String
|
||||
WsEvent.ToolProgress(name, preview, sessionId)
|
||||
WsEvent.ToolProgress(name, preview, sessionId, toolId)
|
||||
}
|
||||
|
||||
"tool.generating" -> {
|
||||
val toolId = payload?.get("tool_id") as? String
|
||||
val name = payload?.get("name") as? String
|
||||
WsEvent.ToolGenerating(name, sessionId)
|
||||
WsEvent.ToolGenerating(name, sessionId, toolId)
|
||||
}
|
||||
|
||||
"subagent.spawn_requested", "subagent.start", "subagent.progress", "subagent.complete" -> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.await
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -106,21 +107,24 @@ object HermesWsClient {
|
||||
|
||||
/**
|
||||
* Liveness thresholds aligned with reference clients (desktop/web):
|
||||
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
|
||||
* Ping every 15s using gateway.ping; 30s inbound silence deadline (issue #1165)
|
||||
* as mobile secondary safety net for faster half-open socket recovery.
|
||||
*/
|
||||
private const val HEARTBEAT_INTERVAL_MS = 15_000L
|
||||
private const val STALE_THRESHOLD_MS = 45_000L
|
||||
private const val STALE_THRESHOLD_MS = 30_000L
|
||||
private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
|
||||
|
||||
// ── Internal state (all access through synchronized / atomic) ────────
|
||||
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
|
||||
private val connectionGeneration = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||
private val appInForeground = AtomicBoolean(true)
|
||||
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||
private val backgroundConnectionLease = AtomicBoolean(false)
|
||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||
private val outboundLock = Any()
|
||||
@@ -351,6 +355,16 @@ object HermesWsClient {
|
||||
wsScope.launch {
|
||||
events.collect { event ->
|
||||
if (event is WsEvent.GatewayReady) {
|
||||
// Advertise support for gateway server→client requests (issue #1197).
|
||||
// Older gateways may reject this method; that is harmless.
|
||||
runCatching {
|
||||
send(
|
||||
WsMethods.CLIENT_CAPABILITIES,
|
||||
mapOf("server_requests" to true),
|
||||
onSent = { id -> capabilityRequestIds.add(id) },
|
||||
queueIfDisconnected = false,
|
||||
)
|
||||
}
|
||||
val epoch = event.data?.get("replay_epoch") as? String
|
||||
if (!epoch.isNullOrEmpty()) {
|
||||
if (replayEpoch != null && replayEpoch != epoch) {
|
||||
@@ -369,6 +383,7 @@ object HermesWsClient {
|
||||
val isConnected: Boolean get() = connected.get()
|
||||
|
||||
private var foregroundProbeJob: Job? = null
|
||||
private var transportProbeJob: Job? = null
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
appInForeground.set(foreground)
|
||||
@@ -398,6 +413,29 @@ object HermesWsClient {
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun probeLivenessOnTransportChange(
|
||||
onFailureAction: () -> Unit = {
|
||||
synchronized(outboundLock) {
|
||||
if (connected.get()) webSocket?.cancel()
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (!connected.get()) return
|
||||
transportProbeJob?.cancel()
|
||||
transportProbeJob =
|
||||
wsScope.launch {
|
||||
Log.d(TAG, "Network transport changed — probing WebSocket liveness")
|
||||
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
|
||||
if (alive) {
|
||||
Log.d(TAG, "WebSocket liveness probe succeeded on new transport")
|
||||
return@launch
|
||||
}
|
||||
Log.w(TAG, "Transport change liveness probe failed — cancelling socket")
|
||||
onFailureAction()
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireExternalActivityConnectionLease() {
|
||||
externalActivityConnectionLease.set(true)
|
||||
}
|
||||
@@ -407,10 +445,23 @@ object HermesWsClient {
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
fun acquireBackgroundConnectionLease() {
|
||||
backgroundConnectionLease.set(true)
|
||||
}
|
||||
|
||||
fun releaseBackgroundConnectionLease() {
|
||||
backgroundConnectionLease.set(false)
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
|
||||
|
||||
private fun disconnectIfIdleInBackground() {
|
||||
synchronized(outboundLock) {
|
||||
if (!appInForeground.get() &&
|
||||
!externalActivityConnectionLease.get() &&
|
||||
!backgroundConnectionLease.get() &&
|
||||
!pendingReply &&
|
||||
pendingCalls.isEmpty() &&
|
||||
messageQueue.isEmpty()
|
||||
@@ -428,6 +479,11 @@ object HermesWsClient {
|
||||
reconnectForNetworkChange(networkAvailable)
|
||||
}
|
||||
}
|
||||
wsScope.launch {
|
||||
NetworkMonitor.transportChanges.collect {
|
||||
probeLivenessOnTransportChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
var socketToCancel: WebSocket? = null
|
||||
val shouldOpen =
|
||||
@@ -517,7 +573,7 @@ object HermesWsClient {
|
||||
* or because ticket refresh succeeded). Returns false if we are in gated mode
|
||||
* and ticket refresh failed or cannot be performed.
|
||||
*/
|
||||
internal fun refreshWsTicketIfNeeded(generation: Int): Boolean {
|
||||
internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
|
||||
val isGated =
|
||||
try {
|
||||
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||
@@ -533,11 +589,9 @@ object HermesWsClient {
|
||||
// restart. Refresh it before each WebSocket handshake so automatic
|
||||
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
|
||||
val token =
|
||||
synchronized(DashboardSessionTokenRefresher) {
|
||||
runCatching {
|
||||
DashboardSessionTokenRefresher.fetch(AuthManager.baseUrl(), OkHttpProvider.probe)
|
||||
}.getOrNull()
|
||||
}
|
||||
runCatching {
|
||||
DashboardSessionTokenRefresher.refreshAsync()
|
||||
}.getOrNull()
|
||||
synchronized(outboundLock) {
|
||||
if (isCurrentGeneration(generation) && token != null) {
|
||||
runCatching { AuthManager.setToken(token) }
|
||||
@@ -580,7 +634,7 @@ object HermesWsClient {
|
||||
* ticket. Loopback mode: refresh the dashboard token and return it.
|
||||
* Returns null when a ticket could not be obtained.
|
||||
*/
|
||||
internal fun mintWsTicket(): String? {
|
||||
internal suspend fun mintWsTicket(): String? {
|
||||
val isGated =
|
||||
try {
|
||||
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||
@@ -588,7 +642,7 @@ object HermesWsClient {
|
||||
false
|
||||
}
|
||||
if (!isGated) {
|
||||
DashboardSessionTokenRefresher.refresh()
|
||||
DashboardSessionTokenRefresher.refreshAsync()
|
||||
return AuthManager.getToken()
|
||||
}
|
||||
return requestWsTicket().ticket
|
||||
@@ -601,7 +655,7 @@ object HermesWsClient {
|
||||
)
|
||||
|
||||
/** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */
|
||||
private fun requestWsTicket(): TicketRequestResult {
|
||||
private suspend fun requestWsTicket(): TicketRequestResult {
|
||||
try {
|
||||
val client = OkHttpProvider.probe
|
||||
val request =
|
||||
@@ -611,16 +665,12 @@ object HermesWsClient {
|
||||
.post("{}".toRequestBody())
|
||||
.build()
|
||||
|
||||
// Run the ENTIRE call on Dispatchers.IO — execute() already hops,
|
||||
// but ResponseBody.string() reads the socket on the CALLING
|
||||
// thread. When the caller is main (kanban events connect), that
|
||||
// read throws NetworkOnMainThreadException and the mint fails.
|
||||
val (code, body) =
|
||||
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (CookieManager.isInitialized()) {
|
||||
CookieManager.useStore(CookieManager.cookieJar.currentServer())
|
||||
}
|
||||
client.newCall(request).execute().use { resp ->
|
||||
client.newCall(request).await().use { resp ->
|
||||
resp.code to resp.body.string()
|
||||
}
|
||||
}
|
||||
@@ -682,7 +732,9 @@ object HermesWsClient {
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
closingSocket = null
|
||||
capabilityRequestIds.clear()
|
||||
if (clearPendingMessages) {
|
||||
backgroundConnectionLease.set(false)
|
||||
messageQueue.clear()
|
||||
queuedMessagesById.clear()
|
||||
pendingPromptSubmits.clear()
|
||||
@@ -869,6 +921,13 @@ object HermesWsClient {
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String = send(method, params, onSent, true)
|
||||
|
||||
private fun send(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
onSent: ((String) -> Unit)?,
|
||||
queueIfDisconnected: Boolean,
|
||||
): String {
|
||||
val id = requestId.incrementAndGet().toString()
|
||||
val decoratedParams = WsProfileParams.decorate(method, params)
|
||||
@@ -888,13 +947,15 @@ object HermesWsClient {
|
||||
pendingPromptSubmits.add(id)
|
||||
pendingReply = true
|
||||
}
|
||||
onSent?.invoke(id)
|
||||
val ws = webSocket
|
||||
if (ws != null && connected.get()) {
|
||||
// Never replay send(true): the server may have executed it even if its response is lost.
|
||||
if (!ws.send(json)) {
|
||||
if (ws.send(json)) {
|
||||
onSent?.invoke(id)
|
||||
} else {
|
||||
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||
if (isRetryableMessage(json)) {
|
||||
if (queueIfDisconnected && isRetryableMessage(json)) {
|
||||
onSent?.invoke(id)
|
||||
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
|
||||
queueMessage(id, json)
|
||||
recoverRejectedSocket(ws)
|
||||
@@ -902,8 +963,9 @@ object HermesWsClient {
|
||||
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
|
||||
}
|
||||
}
|
||||
} else if (acceptQueuedMessages.get()) {
|
||||
} else if (acceptQueuedMessages.get() && queueIfDisconnected) {
|
||||
if (isRetryableMessage(json)) {
|
||||
onSent?.invoke(id)
|
||||
Log.d(TAG, "WS disconnected — queuing message")
|
||||
queueMessage(id, json)
|
||||
reconnect = true
|
||||
@@ -1154,22 +1216,24 @@ object HermesWsClient {
|
||||
if (intentionalClose.get()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
}
|
||||
if (!refreshWsTicketIfNeeded(generation)) {
|
||||
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||
return
|
||||
}
|
||||
if (!isCurrentGeneration(generation)) return
|
||||
val url = AuthManager.wsUrl()
|
||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
wsScope.launch(Dispatchers.IO) {
|
||||
if (!refreshWsTicketIfNeeded(generation)) {
|
||||
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||
return@launch
|
||||
}
|
||||
if (!isCurrentGeneration(generation)) return@launch
|
||||
val url = AuthManager.wsUrl()
|
||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||
synchronized(outboundLock) {
|
||||
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||
webSocket = newSocket
|
||||
} else {
|
||||
newSocket.cancel()
|
||||
val request = Request.Builder().url(url).build()
|
||||
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||
synchronized(outboundLock) {
|
||||
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||
webSocket = newSocket
|
||||
} else {
|
||||
newSocket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1237,6 +1301,26 @@ object HermesWsClient {
|
||||
) : WebSocketListener() {
|
||||
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
|
||||
|
||||
private fun consumeCapabilityResponse(event: WsEvent): Boolean {
|
||||
val id =
|
||||
when (event) {
|
||||
is WsEvent.RpcResult -> event.id
|
||||
is WsEvent.RpcError -> event.id
|
||||
else -> return false
|
||||
}
|
||||
// send() registers capability request IDs via onSent while holding
|
||||
// outboundLock. Take the same lock here so a very fast gateway
|
||||
// response cannot be consumed before its ID has been registered.
|
||||
val isCapabilityResponse =
|
||||
synchronized(outboundLock) {
|
||||
capabilityRequestIds.remove(id)
|
||||
}
|
||||
if (!isCapabilityResponse) return false
|
||||
|
||||
removeQueuedMessage(id)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
@@ -1316,12 +1400,9 @@ object HermesWsClient {
|
||||
}
|
||||
|
||||
if (rpc.id == null) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = rpc.params?.toAny() as? Map<String, Any?>
|
||||
val sid =
|
||||
(params?.get("session_id") as? String)
|
||||
?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String)
|
||||
val seq = (params?.get("seq") as? Number)?.toInt()
|
||||
// Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
|
||||
val sid = rpc.params?.eventSessionId()
|
||||
val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
|
||||
|
||||
if (!sid.isNullOrBlank() && seq != null) {
|
||||
val prev = lastSeenSeq[sid] ?: 0
|
||||
@@ -1360,6 +1441,7 @@ object HermesWsClient {
|
||||
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
|
||||
WsEvent.Unknown(text)
|
||||
}
|
||||
if (consumeCapabilityResponse(event)) return
|
||||
when (event) {
|
||||
is WsEvent.RpcResult -> {
|
||||
synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) }
|
||||
@@ -1426,6 +1508,7 @@ object HermesWsClient {
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
capabilityRequestIds.clear()
|
||||
if (isTerminalAuthClose(code, reason)) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||
@@ -1454,6 +1537,7 @@ object HermesWsClient {
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
capabilityRequestIds.clear()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || code == 4401 || code == 4403 ||
|
||||
t.message?.contains("401") == true ||
|
||||
|
||||
@@ -102,6 +102,13 @@ fun Any?.toJsonElement(): JsonElement =
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #1163: scalar conversion preserves toAny() type semantics without copying JSON containers.
|
||||
internal fun JsonElement?.eventStringOrNull(): String? = (this as? JsonPrimitive)?.toAny() as? String
|
||||
|
||||
internal fun JsonObject.eventSessionId(): String? =
|
||||
get("session_id").eventStringOrNull()
|
||||
?: (get("payload") as? JsonObject)?.get("session_id").eventStringOrNull()
|
||||
|
||||
fun JsonElement.toAny(): Any? =
|
||||
when (this) {
|
||||
is JsonNull -> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -163,86 +164,89 @@ class KanbanEventsClient {
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
|
||||
val credential = HermesWsClient.mintWsTicket()
|
||||
if (credential.isNullOrBlank()) {
|
||||
onStatus(KanbanLiveStatus.AUTH_FAILED)
|
||||
return
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val credential = HermesWsClient.mintWsTicket()
|
||||
if (closed || gen != generation) return@launch
|
||||
if (credential.isNullOrBlank()) {
|
||||
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
|
||||
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
|
||||
val url =
|
||||
AuthManager.endpointForBuild().webSocketUrl(
|
||||
path = KANBAN_EVENTS_PATH,
|
||||
authParameter = authParam,
|
||||
credential = credential,
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
||||
)
|
||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
|
||||
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
|
||||
val url =
|
||||
AuthManager.endpointForBuild().webSocketUrl(
|
||||
path = KANBAN_EVENTS_PATH,
|
||||
authParameter = authParam,
|
||||
credential = credential,
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
||||
)
|
||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket =
|
||||
httpClient.newWebSocket(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
backoffMs = INITIAL_BACKOFF_MS
|
||||
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
val envelope = parseKanbanEventsFrame(text) ?: return
|
||||
if (envelope.events.isEmpty()) return
|
||||
envelope.cursor?.let { cursor = it }
|
||||
scope.launch { onEvents(envelope) }
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) = Unit
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
if (code == WS_1008_POLICY_VIOLATION) {
|
||||
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
|
||||
return
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket =
|
||||
httpClient.newWebSocket(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
backoffMs = INITIAL_BACKOFF_MS
|
||||
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
|
||||
}
|
||||
scheduleReconnect(scope, gen, board, onEvents, onStatus)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
|
||||
scheduleReconnect(scope, gen, board, onEvents, onStatus)
|
||||
}
|
||||
},
|
||||
)
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
val envelope = parseKanbanEventsFrame(text) ?: return
|
||||
if (envelope.events.isEmpty()) return
|
||||
envelope.cursor?.let { cursor = it }
|
||||
scope.launch { onEvents(envelope) }
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) = Unit
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
if (code == WS_1008_POLICY_VIOLATION) {
|
||||
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
|
||||
return
|
||||
}
|
||||
scheduleReconnect(scope, gen, board, onEvents, onStatus)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
if (closed || gen != generation) return
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
|
||||
scheduleReconnect(scope, gen, board, onEvents, onStatus)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/** Issue #1164: profile-configured inventory; never pass a live chat session override. */
|
||||
class ModelOptionsRepository(
|
||||
private val connected: () -> Boolean = {
|
||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
},
|
||||
private val request: suspend (Map<String, Any>) -> Any? = { params ->
|
||||
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
if (!deferred.isCompleted) deferred.cancel()
|
||||
}
|
||||
},
|
||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||
},
|
||||
) {
|
||||
suspend fun load(refresh: Boolean = false): NetworkResult<ModelOptionsResponse> {
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (connected()) {
|
||||
try {
|
||||
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
|
||||
// request() returns raw JsonElement, unlike the pushed-event Map representation.
|
||||
val json = result as? JsonElement ?: error("Invalid model.options result")
|
||||
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
|
||||
} catch (e: CancellationException) {
|
||||
// A disconnected socket may cancel the independent RPC deferred. Only fall back
|
||||
// if the screen's own coroutine is still active; never swallow caller cancellation.
|
||||
currentCoroutineContext().ensureActive()
|
||||
} catch (_: Exception) {
|
||||
// Older gateways and malformed payloads keep the existing HTTP behavior.
|
||||
}
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
return rest(refresh)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,11 @@ sealed class WsEvent {
|
||||
val storedSessionId: String? = null,
|
||||
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
|
||||
val rawPayload: Map<String, Any?>? = null,
|
||||
/** Stable completion identity for notification and read-tracking correlation. */
|
||||
val completionId: String =
|
||||
java.util.UUID
|
||||
.randomUUID()
|
||||
.toString(),
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
@@ -104,23 +109,25 @@ sealed class WsEvent {
|
||||
* Live tool execution progress with optional preview content.
|
||||
*
|
||||
* Events: `tool.progress`
|
||||
* Payload: `{ name?: string, preview?: string }`
|
||||
* Payload: `{ tool_id?: string, name?: string, preview?: string }`
|
||||
*/
|
||||
data class ToolProgress(
|
||||
val name: String? = null,
|
||||
val preview: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val toolId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Tool generation active state.
|
||||
*
|
||||
* Events: `tool.generating`
|
||||
* Payload: `{ name?: string }`
|
||||
* Payload: `{ tool_id?: string, name?: string }`
|
||||
*/
|
||||
data class ToolGenerating(
|
||||
val name: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val toolId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ object WsMethods {
|
||||
const val SESSION_USAGE = "session.usage"
|
||||
const val PROFILES_CONFIGURE = "profiles.configure"
|
||||
const val PROFILES_LIST = "profiles.list"
|
||||
const val MODEL_OPTIONS = "model.options"
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,6 +66,7 @@ object WsMethods {
|
||||
|
||||
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
|
||||
const val GATEWAY_PING = "gateway.ping"
|
||||
const val CLIENT_CAPABILITIES = "client.capabilities"
|
||||
|
||||
// ── Commands catalog ──────────────────────────────────────────────────
|
||||
const val COMMANDS_CATALOG = "commands.catalog"
|
||||
@@ -154,6 +156,7 @@ object WsMethods {
|
||||
// Evidence: methods_session.py lines 42, 164, 232, 317, 845
|
||||
SESSION_CREATE, // session.create
|
||||
SESSION_LIST, // session.list
|
||||
MODEL_OPTIONS, // model.options: @_profile_scoped
|
||||
SESSION_RESUME, // session.resume
|
||||
SESSION_DELETE, // session.delete
|
||||
SESSION_STATUS, // session.status
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
|
||||
/**
|
||||
* Coordinates background connection leases and foreground service lifecycle
|
||||
* based on [BackgroundConnectionPolicy].
|
||||
*/
|
||||
class BackgroundConnectionController(
|
||||
private val snapshotProvider: (isDeparting: Boolean) -> BackgroundConnectionSnapshot = { isDeparting ->
|
||||
defaultSnapshot(isDeparting)
|
||||
},
|
||||
private val acquireLease: () -> Unit = { HermesWsClient.acquireBackgroundConnectionLease() },
|
||||
private val releaseLease: () -> Unit = { HermesWsClient.releaseBackgroundConnectionLease() },
|
||||
private val requestServiceStart: (() -> Unit) -> Unit = { startAction ->
|
||||
ChatNotificationService.lifecycle.start(startAction)
|
||||
},
|
||||
private val requestServiceStop: () -> Unit = { ChatNotificationService.lifecycle.stop() },
|
||||
private val requestServiceComplete: (Long) -> Unit = { generation ->
|
||||
ChatNotificationService.lifecycle.complete(generation)
|
||||
},
|
||||
private val onNotificationStateChanged: (BackgroundNotificationState) -> Unit = { state ->
|
||||
ChatNotificationService.updateForegroundNotification(state)
|
||||
},
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BgConnectionController"
|
||||
|
||||
val default: BackgroundConnectionController by lazy { BackgroundConnectionController() }
|
||||
|
||||
fun defaultSnapshot(isDeparting: Boolean = false): BackgroundConnectionSnapshot {
|
||||
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) {
|
||||
return BackgroundConnectionSnapshot.ineligible(
|
||||
appInForeground = ChatNotificationService.isAppInForeground(),
|
||||
isDeparting = isDeparting,
|
||||
)
|
||||
}
|
||||
|
||||
val status = HermesWsClient.connectionStatus.value
|
||||
val isEligible = AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()
|
||||
|
||||
return BackgroundConnectionSnapshot(
|
||||
appInForeground = ChatNotificationService.isAppInForeground(),
|
||||
isDeparting = isDeparting,
|
||||
keepConnectedOptIn = AuthManager.isKeepConnectedInBackground(),
|
||||
pendingReply = HermesWsClient.pendingReply,
|
||||
isEligibleForConnection = isEligible,
|
||||
status = status,
|
||||
isAutoReconnect = AuthManager.isAutoReconnect(),
|
||||
hasActiveNetwork = NetworkMonitor.isConnected.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentNotificationState: BackgroundNotificationState = BackgroundNotificationState.None
|
||||
|
||||
fun onAppPause(startServiceAction: () -> Unit) {
|
||||
val snapshot = snapshotProvider(true)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
||||
|
||||
if (!decision.shouldHoldService) {
|
||||
return
|
||||
}
|
||||
|
||||
if (decision.shouldHoldPersistentLease) {
|
||||
acquireLease()
|
||||
}
|
||||
|
||||
try {
|
||||
requestServiceStart(startServiceAction)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start background service, rolling back lease", e)
|
||||
if (decision.shouldHoldPersistentLease) {
|
||||
releaseLease()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onAppResume() {
|
||||
releaseLease()
|
||||
requestServiceStop()
|
||||
}
|
||||
|
||||
fun onReplyCompleted(generation: Long) {
|
||||
val snapshot = snapshotProvider(false).copy(pendingReply = false)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
||||
|
||||
if (decision.shouldHoldService) {
|
||||
if (decision.notificationState != currentNotificationState) {
|
||||
currentNotificationState = decision.notificationState
|
||||
onNotificationStateChanged(decision.notificationState)
|
||||
}
|
||||
} else {
|
||||
requestServiceComplete(generation)
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcileState() {
|
||||
val snapshot = snapshotProvider(false)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
||||
|
||||
if (!decision.shouldHoldService && !snapshot.appInForeground) {
|
||||
releaseLease()
|
||||
requestServiceStop()
|
||||
} else if (decision.shouldHoldService) {
|
||||
if (decision.notificationState != currentNotificationState) {
|
||||
currentNotificationState = decision.notificationState
|
||||
onNotificationStateChanged(decision.notificationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
|
||||
/**
|
||||
* Snapshot of runtime state required to determine background connection policy.
|
||||
*/
|
||||
data class BackgroundConnectionSnapshot(
|
||||
val appInForeground: Boolean,
|
||||
val isDeparting: Boolean = false,
|
||||
val keepConnectedOptIn: Boolean,
|
||||
val pendingReply: Boolean,
|
||||
val isEligibleForConnection: Boolean,
|
||||
val status: ConnectionStatus,
|
||||
val isAutoReconnect: Boolean = true,
|
||||
val hasActiveNetwork: Boolean = true,
|
||||
) {
|
||||
companion object {
|
||||
fun ineligible(
|
||||
appInForeground: Boolean = false,
|
||||
isDeparting: Boolean = false,
|
||||
): BackgroundConnectionSnapshot =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = appInForeground,
|
||||
isDeparting = isDeparting,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = false,
|
||||
isEligibleForConnection = false,
|
||||
status = ConnectionStatus.DISCONNECTED,
|
||||
isAutoReconnect = false,
|
||||
hasActiveNetwork = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-visible state of the ongoing background notification.
|
||||
*/
|
||||
enum class BackgroundNotificationState {
|
||||
None,
|
||||
WaitingForNetwork,
|
||||
Connecting,
|
||||
Reconnecting,
|
||||
WaitingForReplies,
|
||||
ConnectedInBackground,
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluated decision for foreground service and connection retention.
|
||||
*/
|
||||
data class BackgroundConnectionDecision(
|
||||
val shouldHoldService: Boolean,
|
||||
val shouldHoldPersistentLease: Boolean,
|
||||
val notificationState: BackgroundNotificationState,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure policy evaluation for background connection lifecycle.
|
||||
*/
|
||||
object BackgroundConnectionPolicy {
|
||||
fun evaluate(snapshot: BackgroundConnectionSnapshot): BackgroundConnectionDecision {
|
||||
// Terminal failure / unauthenticated / ineligible state
|
||||
if (!snapshot.isEligibleForConnection || snapshot.status == ConnectionStatus.AUTH_EXPIRED) {
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = false,
|
||||
shouldHoldPersistentLease = false,
|
||||
notificationState = BackgroundNotificationState.None,
|
||||
)
|
||||
}
|
||||
|
||||
// When the app is in foreground and not departing, background service/lease are not needed
|
||||
if (snapshot.appInForeground && !snapshot.isDeparting) {
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = false,
|
||||
shouldHoldPersistentLease = false,
|
||||
notificationState = BackgroundNotificationState.None,
|
||||
)
|
||||
}
|
||||
|
||||
// Demand check: need either a pending reply or persistent opt-in
|
||||
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
|
||||
if (!hasDemand) {
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = false,
|
||||
shouldHoldPersistentLease = false,
|
||||
notificationState = BackgroundNotificationState.None,
|
||||
)
|
||||
}
|
||||
|
||||
// When Auto-Reconnect is disabled, HermesWsClient will not reconnect once disconnected or offline.
|
||||
// Even if a reply was pending, it can never arrive without reconnecting.
|
||||
if (!snapshot.isAutoReconnect) {
|
||||
when (snapshot.status) {
|
||||
ConnectionStatus.DISCONNECTED,
|
||||
ConnectionStatus.NO_NETWORK,
|
||||
-> {
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = false,
|
||||
shouldHoldPersistentLease = false,
|
||||
notificationState = BackgroundNotificationState.None,
|
||||
)
|
||||
}
|
||||
|
||||
else -> { /* CONNECTED, CONNECTING, RECONNECTING can proceed */ }
|
||||
}
|
||||
}
|
||||
|
||||
val notificationState =
|
||||
when {
|
||||
!snapshot.hasActiveNetwork || snapshot.status == ConnectionStatus.NO_NETWORK -> {
|
||||
BackgroundNotificationState.WaitingForNetwork
|
||||
}
|
||||
|
||||
snapshot.status == ConnectionStatus.RECONNECTING -> {
|
||||
BackgroundNotificationState.Reconnecting
|
||||
}
|
||||
|
||||
snapshot.pendingReply -> {
|
||||
BackgroundNotificationState.WaitingForReplies
|
||||
}
|
||||
|
||||
snapshot.status == ConnectionStatus.CONNECTED -> {
|
||||
BackgroundNotificationState.ConnectedInBackground
|
||||
}
|
||||
|
||||
snapshot.status == ConnectionStatus.CONNECTING -> {
|
||||
BackgroundNotificationState.Connecting
|
||||
}
|
||||
|
||||
else -> {
|
||||
BackgroundNotificationState.Reconnecting
|
||||
}
|
||||
}
|
||||
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = true,
|
||||
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
|
||||
notificationState = notificationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.m57.hermescontrol.MainActivity
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
@@ -43,16 +43,16 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
* message notifications, which is correct.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Started by [NotificationHelper.start] when the app goes to the
|
||||
* background while a reply is still pending (the user sent a message or
|
||||
* replied from a notification and the agent has not finished yet).
|
||||
* [NotificationHelper.start] is a no-op when nothing is pending, so the
|
||||
* service — and its mandatory persistent notification — only exists
|
||||
* while the user is actually waiting for a reply (issue #794).
|
||||
* - Stopped by [NotificationHelper.stop] when the app returns to the
|
||||
* foreground (called from MainActivity.onStart), or by the
|
||||
* service itself once the pending reply completes in the background
|
||||
* (the reply notification replaces the persistent "waiting" one).
|
||||
* - Started by [NotificationHelper.start] when the app goes to the background
|
||||
* either while a reply is still pending (issue #794) or when persistent background
|
||||
* connection ([com.m57.hermescontrol.data.local.AuthManager.isKeepConnectedInBackground])
|
||||
* is enabled.
|
||||
* - In persistent keep-connected mode, the service stays alive across idle states
|
||||
* and updates its ongoing notification truthfully ([BackgroundNotificationState]).
|
||||
* - In replies-only mode (default), the service retires itself once the pending reply
|
||||
* completes in the background.
|
||||
* - Stopped by [NotificationHelper.stop] when the app returns to the foreground, or
|
||||
* retired automatically upon auth expiry or terminal disconnection.
|
||||
*
|
||||
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
|
||||
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
|
||||
@@ -68,20 +68,36 @@ class ChatNotificationService : Service() {
|
||||
private val isAppInForeground = AtomicBoolean(false)
|
||||
internal val lifecycle = ForegroundServiceLifecycle()
|
||||
|
||||
/**
|
||||
* Turn-row correlation used when a reply completes in the background.
|
||||
* Injectable so the resolution path can be exercised without REST.
|
||||
*/
|
||||
internal var turnRowResolver: TurnRowResolver = defaultTurnRowResolver
|
||||
|
||||
@Volatile
|
||||
internal var activeServiceInstance: ChatNotificationService? = null
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
isAppInForeground.set(foreground)
|
||||
}
|
||||
|
||||
fun isAppInForeground(): Boolean = isAppInForeground.get()
|
||||
|
||||
fun updateForegroundNotification(state: BackgroundNotificationState) {
|
||||
activeServiceInstance?.updateNotification(state)
|
||||
}
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var eventCollector: Job? = null
|
||||
private var statusCollector: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
activeServiceInstance = this
|
||||
createNotificationChannels()
|
||||
startEventCollection()
|
||||
startStatusCollection()
|
||||
}
|
||||
|
||||
private fun startEventCollection() {
|
||||
@@ -100,10 +116,23 @@ class ChatNotificationService : Service() {
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { getString(R.string.notif_new_message) }
|
||||
showReplyNotification(
|
||||
preview,
|
||||
val targetSessionId =
|
||||
event.storedSessionId
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||
showReplyNotification(
|
||||
text = preview,
|
||||
sessionId = targetSessionId,
|
||||
isReplyMessage = true,
|
||||
completionId = event.completionId,
|
||||
// The durable REST row for this turn, when the
|
||||
// boundary armed before the prompt was submitted
|
||||
// still lets us name it unambiguously. Null is a
|
||||
// normal, safe outcome: the notification is then
|
||||
// never auto-dismissed from REST hydration, which
|
||||
// is strictly better than dismissing the wrong
|
||||
// duplicate reply.
|
||||
serverMessageId =
|
||||
coalesceTurnRow(targetSessionId, event.text),
|
||||
)
|
||||
// The wait is over — retire the foreground
|
||||
// service. The reply notification above
|
||||
@@ -113,7 +142,7 @@ class ChatNotificationService : Service() {
|
||||
// service is not restarted on the next
|
||||
// ON_STOP (issue #794).
|
||||
// A delayed completion must not retire a newer turn/start.
|
||||
if (!HermesWsClient.pendingReply) lifecycle.complete(generation)
|
||||
BackgroundConnectionController.default.onReplyCompleted(generation)
|
||||
}
|
||||
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
@@ -156,9 +185,37 @@ class ChatNotificationService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the exact REST row this completion produced, using the turn
|
||||
* boundary armed before the prompt was submitted. Any failure to prove that
|
||||
* identity returns null — the notification is still posted, it just will not
|
||||
* be auto-dismissed from REST hydration.
|
||||
*/
|
||||
private suspend fun coalesceTurnRow(
|
||||
sessionId: String?,
|
||||
completionText: String,
|
||||
): Int? {
|
||||
if (sessionId.isNullOrBlank()) return null
|
||||
return try {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = correlationScopeId(),
|
||||
sessionId = sessionId,
|
||||
completionText = completionText,
|
||||
resolver = turnRowResolver,
|
||||
)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun showReplyNotification(
|
||||
text: String,
|
||||
sessionId: String?,
|
||||
isReplyMessage: Boolean = false,
|
||||
completionId: String? = null,
|
||||
serverMessageId: Int? = null,
|
||||
) {
|
||||
val builder =
|
||||
NotificationCompat
|
||||
@@ -172,6 +229,39 @@ class ChatNotificationService : Service() {
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(sessionId))
|
||||
|
||||
var replyGeneration: Long? = null
|
||||
if (isReplyMessage && !sessionId.isNullOrBlank() && !completionId.isNullOrBlank()) {
|
||||
val scopeId = correlationScopeId()
|
||||
val generation =
|
||||
ReplyNotificationTracker.registerPendingReply(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = text,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
replyGeneration = generation
|
||||
builder.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLY)
|
||||
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
|
||||
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, text)
|
||||
putLong(ReplyNotificationTracker.EXTRA_GENERATION, generation)
|
||||
serverMessageId?.let {
|
||||
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
builder.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_ACTION)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
val replyLabel = getString(R.string.notif_reply_placeholder)
|
||||
val remoteInput =
|
||||
@@ -183,6 +273,11 @@ class ChatNotificationService : Service() {
|
||||
val replyIntent =
|
||||
Intent(this, NotificationReplyReceiver::class.java).apply {
|
||||
action = "$packageName.ACTION_NOTIFICATION_REPLY"
|
||||
component =
|
||||
android.content.ComponentName(
|
||||
this@ChatNotificationService,
|
||||
NotificationReplyReceiver::class.java,
|
||||
)
|
||||
setPackage(packageName)
|
||||
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
@@ -207,19 +302,32 @@ class ChatNotificationService : Service() {
|
||||
builder.addAction(action)
|
||||
}
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(PENDING_NOTIFICATION_ID, builder.build())
|
||||
val notification = builder.build()
|
||||
if (replyGeneration != null) {
|
||||
ReplyNotificationTracker.postReplyNotification(
|
||||
context = this,
|
||||
notification = notification,
|
||||
generation = replyGeneration,
|
||||
)
|
||||
} else {
|
||||
ReplyNotificationTracker.postActionNotification(
|
||||
context = this,
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildContentIntent(sessionId: String?): PendingIntent {
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
action = MainActivity.ACTION_OPEN_CHAT_FROM_NOTIFICATION
|
||||
component = android.content.ComponentName(this@ChatNotificationService, MainActivity::class.java)
|
||||
setPackage(packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
}
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
intent.putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
sessionId?.hashCode() ?: 0,
|
||||
@@ -269,10 +377,13 @@ class ChatNotificationService : Service() {
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
val initialDecision =
|
||||
BackgroundConnectionPolicy.evaluate(BackgroundConnectionController.defaultSnapshot(isDeparting = true))
|
||||
val initialText = resolveNotificationText(initialDecision.notificationState)
|
||||
lifecycle.onStart(
|
||||
owner = this,
|
||||
promote = {
|
||||
startForeground(NOTIFICATION_ID, buildForegroundNotification(getString(R.string.notif_waiting_replies)))
|
||||
startForeground(NOTIFICATION_ID, buildForegroundNotification(initialText))
|
||||
},
|
||||
// Do not remove foreground status before Android accepts retirement:
|
||||
// a newer start may already be queued, but not delivered to us yet.
|
||||
@@ -284,11 +395,52 @@ class ChatNotificationService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeServiceInstance === this) {
|
||||
activeServiceInstance = null
|
||||
}
|
||||
lifecycle.onDestroyed(this)
|
||||
eventCollector?.cancel()
|
||||
statusCollector?.cancel()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startStatusCollection() {
|
||||
statusCollector =
|
||||
serviceScope.launch {
|
||||
launch {
|
||||
NetworkMonitor.networkChanges.collect {
|
||||
if (!isAppInForeground.get()) {
|
||||
BackgroundConnectionController.default.reconcileState()
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
HermesWsClient.connectionStatus.collect {
|
||||
if (!isAppInForeground.get()) {
|
||||
BackgroundConnectionController.default.reconcileState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateNotification(state: BackgroundNotificationState) {
|
||||
if (isAppInForeground.get()) return
|
||||
val text = resolveNotificationText(state)
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
|
||||
manager.notify(NOTIFICATION_ID, buildForegroundNotification(text))
|
||||
}
|
||||
|
||||
private fun resolveNotificationText(state: BackgroundNotificationState): String =
|
||||
when (state) {
|
||||
BackgroundNotificationState.WaitingForNetwork -> getString(R.string.notif_waiting_network)
|
||||
BackgroundNotificationState.Connecting -> getString(R.string.notif_connecting)
|
||||
BackgroundNotificationState.Reconnecting -> getString(R.string.notif_reconnecting)
|
||||
BackgroundNotificationState.WaitingForReplies -> getString(R.string.notif_waiting_replies)
|
||||
BackgroundNotificationState.ConnectedInBackground -> getString(R.string.notif_connected_in_background)
|
||||
BackgroundNotificationState.None -> getString(R.string.notif_waiting_replies)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,14 +448,8 @@ class ChatNotificationService : Service() {
|
||||
*/
|
||||
object NotificationHelper {
|
||||
fun start(context: Context) {
|
||||
if (AuthManager.getToken().isNullOrBlank()) return
|
||||
// Only run the foreground service while a reply is actually pending
|
||||
// (issue #794) — otherwise the mandatory persistent "Waiting for
|
||||
// Hermes replies" notification appears on every background even
|
||||
// when nothing is in flight.
|
||||
if (!HermesWsClient.pendingReply) return
|
||||
val intent = Intent(context, ChatNotificationService::class.java)
|
||||
ChatNotificationService.lifecycle.start {
|
||||
BackgroundConnectionController.default.onAppPause {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -313,9 +459,7 @@ object NotificationHelper {
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
// Rotation can resume the activity before the queued service is created.
|
||||
// Direct stopService here crashes Android while foreground promotion is owed.
|
||||
ChatNotificationService.lifecycle.stop()
|
||||
BackgroundConnectionController.default.onAppResume()
|
||||
}
|
||||
|
||||
fun setAppForeground(
|
||||
@@ -324,6 +468,9 @@ object NotificationHelper {
|
||||
) {
|
||||
ChatNotificationService.setAppForeground(foreground)
|
||||
HermesWsClient.setAppForeground(foreground)
|
||||
if (!foreground) {
|
||||
BackgroundConnectionController.default.reconcileState()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
|
||||
|
||||
@@ -24,6 +24,13 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
||||
private const val REPLY_TIMEOUT_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
|
||||
* the probe sits on the reply path, so a slow or unreachable gateway must
|
||||
* cost the user's reply only a sliver of its 5s deadline.
|
||||
*/
|
||||
private const val BOUNDARY_TIMEOUT_MS = 250L
|
||||
}
|
||||
|
||||
// Reusable scope for async reply processing — avoids creating a new
|
||||
@@ -50,7 +57,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
.setContentText("Replied")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
|
||||
},
|
||||
).build()
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
@@ -78,6 +89,17 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Mobile-originated follow-up turn: arm its durable
|
||||
// boundary BEFORE prompt.submit. Budgeted tighter
|
||||
// than the chat composer's — this receiver has its
|
||||
// own 5s deadline and a failed capture must only
|
||||
// make the reply uncorrelatable, never lose it.
|
||||
captureTurnBoundary(
|
||||
scopeId = correlationScopeId(),
|
||||
sessionId = sessionId,
|
||||
timeoutMs = BOUNDARY_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val runtimeSessionId =
|
||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||
?: resumeSession(sessionId)
|
||||
@@ -102,8 +124,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
dao.upsert(entity)
|
||||
|
||||
val repliedNotification = buildReplyNotification(context)
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)
|
||||
ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
|
||||
|
||||
// The follow-up turn is now pending — keep the
|
||||
// foreground service alive if it retired after the
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
data class ReplyNotificationTarget(
|
||||
val scopeId: String,
|
||||
val sessionId: String,
|
||||
val completionId: String,
|
||||
val generation: Long,
|
||||
val textSnippet: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* Durable REST `messages.id` of the row this reply produced. Resolved by
|
||||
* [TurnCorrelationTracker] from the boundary armed before the prompt was
|
||||
* submitted — never taken from the completion event, text, or a clock. Null
|
||||
* means the turn could not be correlated, and then hydration must not touch
|
||||
* this notification at all (fail closed).
|
||||
*/
|
||||
val serverMessageId: Int? = null,
|
||||
) {
|
||||
fun matches(
|
||||
candidateScopeId: String?,
|
||||
candidateSessionId: String?,
|
||||
candidateCompletionId: String?,
|
||||
): Boolean {
|
||||
if (scopeId.isNotBlank() && scopeId != candidateScopeId) {
|
||||
return false
|
||||
}
|
||||
if (candidateSessionId.isNullOrBlank() || sessionId != candidateSessionId) {
|
||||
return false
|
||||
}
|
||||
if (candidateCompletionId.isNullOrBlank() || completionId.isBlank()) {
|
||||
return false
|
||||
}
|
||||
return candidateCompletionId == completionId
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ActiveReplyInfo(
|
||||
val id: Int,
|
||||
val kind: String?,
|
||||
val scopeId: String?,
|
||||
val sessionId: String?,
|
||||
val completionId: String?,
|
||||
val textSnippet: String?,
|
||||
val generation: Long,
|
||||
val timestamp: Long,
|
||||
val serverMessageId: Int? = null,
|
||||
)
|
||||
|
||||
object ReplyNotificationTracker {
|
||||
const val EXTRA_NOTIF_KIND = "hermes_notif_kind"
|
||||
const val EXTRA_SCOPE_ID = "hermes_scope_id"
|
||||
const val EXTRA_SESSION_ID = "hermes_session_id"
|
||||
const val EXTRA_COMPLETION_ID = "hermes_completion_id"
|
||||
const val EXTRA_TEXT_SNIPPET = "hermes_text_snippet"
|
||||
const val EXTRA_GENERATION = "hermes_generation"
|
||||
const val EXTRA_SERVER_MESSAGE_ID = "hermes_server_message_id"
|
||||
|
||||
const val KIND_REPLY = "reply"
|
||||
const val KIND_ACTION = "action"
|
||||
const val KIND_REPLIED = "replied"
|
||||
|
||||
private val generationCounter = AtomicLong(0L)
|
||||
private val tombstoneGeneration = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
private var activeTarget: ReplyNotificationTarget? = null
|
||||
|
||||
private val defaultActiveNotificationProvider: (Context) -> ActiveReplyInfo? = { context ->
|
||||
runCatching {
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
val sbn =
|
||||
manager?.activeNotifications?.firstOrNull {
|
||||
it.id == ChatNotificationService.PENDING_NOTIFICATION_ID
|
||||
}
|
||||
if (sbn != null) {
|
||||
val extras = sbn.notification?.extras
|
||||
ActiveReplyInfo(
|
||||
id = sbn.id,
|
||||
kind = extras?.getString(EXTRA_NOTIF_KIND),
|
||||
scopeId = extras?.getString(EXTRA_SCOPE_ID),
|
||||
sessionId = extras?.getString(EXTRA_SESSION_ID),
|
||||
completionId = extras?.getString(EXTRA_COMPLETION_ID),
|
||||
textSnippet = extras?.getString(EXTRA_TEXT_SNIPPET),
|
||||
generation = extras?.getLong(EXTRA_GENERATION, 0L) ?: 0L,
|
||||
timestamp = sbn.postTime,
|
||||
serverMessageId = extras?.getInt(EXTRA_SERVER_MESSAGE_ID, -1)?.takeIf { it >= 0 },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
internal var activeNotificationProvider: (Context) -> ActiveReplyInfo? = defaultActiveNotificationProvider
|
||||
|
||||
fun nextGeneration(): Long {
|
||||
val floor = maxOf(generationCounter.get(), tombstoneGeneration.get())
|
||||
return generationCounter.updateAndGet { maxOf(it, floor) + 1 }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun registerPendingReply(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionId: String,
|
||||
textSnippet: String,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
serverMessageId: Int? = null,
|
||||
): Long {
|
||||
val generation = nextGeneration()
|
||||
activeTarget =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
generation = generation,
|
||||
textSnippet = textSnippet,
|
||||
timestamp = timestamp,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
return generation
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postReplyNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
generation: Long,
|
||||
): Boolean {
|
||||
if (generation <= tombstoneGeneration.get()) {
|
||||
return false
|
||||
}
|
||||
val current = activeTarget
|
||||
if (current != null && current.generation > generation) {
|
||||
return false
|
||||
}
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postActionNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
): Boolean {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postRepliedNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
): Boolean {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onReplyNotificationPosted(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionId: String,
|
||||
textSnippet: String,
|
||||
generation: Long,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
serverMessageId: Int? = null,
|
||||
) {
|
||||
if (generation <= tombstoneGeneration.get()) return
|
||||
generationCounter.updateAndGet { maxOf(it, generation) }
|
||||
activeTarget =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
generation = generation,
|
||||
textSnippet = textSnippet,
|
||||
timestamp = timestamp,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onNonReplyNotificationPosted() {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getActiveTarget(context: Context? = null): ReplyNotificationTarget? {
|
||||
val inMemory = activeTarget
|
||||
if (inMemory != null) return inMemory
|
||||
return if (context != null) resolveTarget(context) else null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onMessageVisible(
|
||||
context: Context,
|
||||
scopeId: String?,
|
||||
sessionId: String?,
|
||||
completionId: String?,
|
||||
): Boolean {
|
||||
if (sessionId.isNullOrBlank()) return false
|
||||
val target = resolveTarget(context) ?: return false
|
||||
if (target.matches(scopeId, sessionId, completionId)) {
|
||||
return cancelReplyNotification(context, target.generation)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelReplyNotification(
|
||||
context: Context,
|
||||
expectedGeneration: Long,
|
||||
): Boolean {
|
||||
val target = resolveTarget(context)
|
||||
if (target != null && target.generation > expectedGeneration) {
|
||||
return false
|
||||
}
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
|
||||
val activeInfo = activeNotificationProvider(context)
|
||||
if (activeInfo != null) {
|
||||
if (activeInfo.kind != KIND_REPLY) {
|
||||
activeTarget = null
|
||||
return false
|
||||
}
|
||||
if (activeInfo.generation <= tombstoneGeneration.get()) {
|
||||
activeTarget = null
|
||||
return false
|
||||
}
|
||||
if (activeInfo.generation > expectedGeneration) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, expectedGeneration) }
|
||||
manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID)
|
||||
activeTarget = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun resolveTarget(context: Context): ReplyNotificationTarget? {
|
||||
val inMemory = activeTarget
|
||||
if (inMemory != null) return inMemory
|
||||
|
||||
val activeInfo = activeNotificationProvider(context) ?: return null
|
||||
if (activeInfo.kind != KIND_REPLY) return null
|
||||
generationCounter.updateAndGet { maxOf(it, activeInfo.generation) }
|
||||
if (activeInfo.generation <= tombstoneGeneration.get()) return null
|
||||
val sessionId = activeInfo.sessionId.orEmpty()
|
||||
if (sessionId.isBlank()) return null
|
||||
|
||||
return ReplyNotificationTarget(
|
||||
scopeId = activeInfo.scopeId.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = activeInfo.completionId.orEmpty(),
|
||||
generation = activeInfo.generation,
|
||||
textSnippet = activeInfo.textSnippet.orEmpty(),
|
||||
timestamp = activeInfo.timestamp,
|
||||
serverMessageId = activeInfo.serverMessageId,
|
||||
).also { activeTarget = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun resetForTest() {
|
||||
activeTarget = null
|
||||
generationCounter.set(0L)
|
||||
tombstoneGeneration.set(0L)
|
||||
activeNotificationProvider = defaultActiveNotificationProvider
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun setTargetForTest(target: ReplyNotificationTarget?) {
|
||||
activeTarget = target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Durable lower bound for one mobile-originated turn: the highest REST
|
||||
* transcript row id that existed immediately BEFORE `prompt.submit` left the
|
||||
* device.
|
||||
*
|
||||
* The id is the gateway's AUTOINCREMENT `messages.id`, so every row persisted
|
||||
* by the turn itself is strictly greater than [beforeMessageId]. That gives the
|
||||
* notification layer a turn-scoped window it can search without text
|
||||
* heuristics, row ordering, or cross-machine clocks.
|
||||
*
|
||||
* Captured before the prompt is submitted, never after: a fast turn can persist
|
||||
* its assistant row before a post-submit snapshot request finishes, which would
|
||||
* place the reply inside the "before" snapshot (and a lower bound can then never
|
||||
* exclude it).
|
||||
*/
|
||||
data class TurnBoundary(
|
||||
val scopeId: String,
|
||||
val sessionId: String,
|
||||
val beforeMessageId: Int,
|
||||
/** Monotonic arm order — a stale clear must not remove a newer turn's boundary. */
|
||||
val generation: Long,
|
||||
val armedAt: Long,
|
||||
) {
|
||||
fun matchesKey(
|
||||
otherScopeId: String,
|
||||
otherSessionId: String,
|
||||
): Boolean = scopeId == otherScopeId && sessionId == otherSessionId
|
||||
}
|
||||
|
||||
/** Persistence seam so the tracker can be unit-tested without an Android context. */
|
||||
internal interface TurnBoundaryStore {
|
||||
fun read(): List<TurnBoundary>
|
||||
|
||||
fun write(boundaries: List<TurnBoundary>)
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedPreferences-backed store — a handful of tiny `scope|session -> state`
|
||||
* rows, so a process death during a running turn does not lose the boundary.
|
||||
*/
|
||||
internal class PrefsTurnBoundaryStore(
|
||||
context: Context,
|
||||
) : TurnBoundaryStore {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun read(): List<TurnBoundary> =
|
||||
prefs.all.mapNotNull { (key, value) -> decodeBoundary(key, value as? String) }
|
||||
|
||||
override fun write(boundaries: List<TurnBoundary>) {
|
||||
val editor = prefs.edit().clear()
|
||||
boundaries.forEach { editor.putString(encodeKey(it.scopeId, it.sessionId), encodeValue(it)) }
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
const val PREFS_NAME = "hermes_turn_boundaries"
|
||||
private const val KEY_SEPARATOR = '|'
|
||||
|
||||
fun encodeKey(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
): String = "$scopeId$KEY_SEPARATOR$sessionId"
|
||||
|
||||
fun encodeValue(boundary: TurnBoundary): String =
|
||||
"${boundary.beforeMessageId}:${boundary.generation}:${boundary.armedAt}"
|
||||
|
||||
fun decodeBoundary(
|
||||
key: String,
|
||||
value: String?,
|
||||
): TurnBoundary? {
|
||||
val separator = key.indexOf(KEY_SEPARATOR)
|
||||
if (separator <= 0 || value == null) return null
|
||||
val parts = value.split(':')
|
||||
if (parts.size != 3) return null
|
||||
val beforeMessageId = parts[0].toIntOrNull() ?: return null
|
||||
val generation = parts[1].toLongOrNull() ?: return null
|
||||
val armedAt = parts[2].toLongOrNull() ?: return null
|
||||
return TurnBoundary(
|
||||
scopeId = key.substring(0, separator),
|
||||
sessionId = key.substring(separator + 1),
|
||||
beforeMessageId = beforeMessageId,
|
||||
generation = generation,
|
||||
armedAt = armedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn-boundary registry for reply-notification correlation.
|
||||
*
|
||||
* A boundary is armed per (profile scope, stored session id) immediately before
|
||||
* a mobile-originated prompt is submitted, and consumed when a completion for
|
||||
* that session is handled. Sessions that never had a boundary (desktop/TUI
|
||||
* turns, another connected client, a failed capture, queued or group-chat
|
||||
* turns) simply fail closed: the reply notification is posted without a durable
|
||||
* row id and therefore is never auto-dismissed from REST hydration alone.
|
||||
*
|
||||
* Scope isolation is structural — a boundary is only visible to the exact
|
||||
* profile that armed it, so switching profiles makes the old boundary
|
||||
* unreachable instead of resolvable against the wrong namespace.
|
||||
*/
|
||||
object TurnCorrelationTracker {
|
||||
/** Bound the registry: stale turns must not accumulate across app restarts. */
|
||||
private const val MAX_ENTRIES = 32
|
||||
|
||||
/** A boundary older than this belongs to a turn nobody is waiting on anymore. */
|
||||
private const val MAX_AGE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val generationCounter = AtomicLong(0L)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cache: List<TurnBoundary> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var store: TurnBoundaryStore? = null
|
||||
|
||||
/** Idempotent — called once from [com.m57.hermescontrol.HermesControlApp]. */
|
||||
fun attach(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (store != null) return
|
||||
// Never let persistence break app startup: an unusable context
|
||||
// degrades to in-memory boundaries.
|
||||
val prefsStore = runCatching { PrefsTurnBoundaryStore(context) }.getOrNull() ?: return
|
||||
store = prefsStore
|
||||
val restored = prune(runCatching { prefsStore.read() }.getOrDefault(emptyList()))
|
||||
cache = restored
|
||||
reserveGenerationFloor(restored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: swap the persistent store (or detach with `null`). */
|
||||
@VisibleForTesting
|
||||
internal fun attachStore(store: TurnBoundaryStore?) {
|
||||
synchronized(lock) {
|
||||
this.store = store
|
||||
val restored = store?.let { prune(it.read()) } ?: emptyList()
|
||||
cache = restored
|
||||
reserveGenerationFloor(restored)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the pre-submit high-watermark. Returns the armed boundary, or null
|
||||
* when the identity is unusable (blank scope/session, no rows and no
|
||||
* confirmed read).
|
||||
*/
|
||||
fun armBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
beforeMessageId: Int,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): TurnBoundary? {
|
||||
if (scopeId.isBlank() || sessionId.isBlank() || beforeMessageId < 0) return null
|
||||
val boundary =
|
||||
TurnBoundary(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
beforeMessageId = beforeMessageId,
|
||||
generation = generationCounter.incrementAndGet(),
|
||||
armedAt = now,
|
||||
)
|
||||
synchronized(lock) {
|
||||
val next = prune(cache.filterNot { it.matchesKey(scopeId, sessionId) } + boundary, now = now)
|
||||
cache = next
|
||||
runCatching { store?.write(next) }
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
fun boundaryFor(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): TurnBoundary? {
|
||||
if (scopeId.isBlank() || sessionId.isBlank()) return null
|
||||
return synchronized(lock) {
|
||||
cache.firstOrNull { it.matchesKey(scopeId, sessionId) && isFresh(it, now) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a boundary. [generation] guards the race where a newer turn armed
|
||||
* a replacement while an older completion was still being correlated.
|
||||
*/
|
||||
fun clearBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
generation: Long? = null,
|
||||
): Boolean {
|
||||
synchronized(lock) {
|
||||
val existing = cache.firstOrNull { it.matchesKey(scopeId, sessionId) } ?: return false
|
||||
if (generation != null && existing.generation != generation) return false
|
||||
val next = cache.filterNot { it === existing }
|
||||
cache = next
|
||||
runCatching { store?.write(next) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun prune(
|
||||
boundaries: List<TurnBoundary>,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): List<TurnBoundary> =
|
||||
boundaries
|
||||
.filter { isFresh(it, now) }
|
||||
.sortedByDescending { it.armedAt }
|
||||
.take(MAX_ENTRIES)
|
||||
|
||||
/**
|
||||
* Keeps generations unique across process death.
|
||||
*
|
||||
* The counter lives in memory only, so after a restart a restored boundary
|
||||
* (generation N) and the next armed boundary would both be N — and the
|
||||
* stale-clear guard, which exists precisely to stop an old completion's
|
||||
* clear from removing a newer turn's boundary, would stop protecting
|
||||
* anything. Lifting the counter above the recovered generations keeps the
|
||||
* guard meaningful.
|
||||
*/
|
||||
private fun reserveGenerationFloor(restored: List<TurnBoundary>) {
|
||||
val maxRestored = restored.maxOfOrNull { it.generation } ?: 0L
|
||||
generationCounter.updateAndGet { maxOf(it, maxRestored) }
|
||||
}
|
||||
|
||||
private fun isFresh(
|
||||
boundary: TurnBoundary,
|
||||
now: Long,
|
||||
): Boolean = now - boundary.armedAt <= MAX_AGE_MS
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun resetForTest() {
|
||||
synchronized(lock) {
|
||||
cache = emptyList()
|
||||
store = null
|
||||
generationCounter.set(0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the pre-submit boundary read may take before the turn goes uncorrelatable. */
|
||||
private const val TURN_BOUNDARY_TIMEOUT_MS = 1_500L
|
||||
|
||||
/**
|
||||
* Correlation scope for the active connection profile.
|
||||
*
|
||||
* `AuthManager.activeProfileId` stays null until a server profile is explicitly
|
||||
* selected, and the rest of AuthManager treats that as
|
||||
* [AuthManager.DEFAULT_PROFILE_ID] (see `currentDataScope()`). Collapsing
|
||||
* null/blank here gives this feature ONE scope identity — a raw `.orEmpty()`
|
||||
* would reject the default profile, so a normal install would arm no boundary,
|
||||
* resolve no row, and silently never auto-dismiss a reply notification.
|
||||
*
|
||||
* The arming side (prompt submit), the resolution side (completion), the
|
||||
* notification extras, and the chat viewport observer must ALL use this, or the
|
||||
* two halves disagree on the scope and dismissal stops working.
|
||||
*/
|
||||
internal fun correlationScopeId(): String =
|
||||
AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||
?: AuthManager.DEFAULT_PROFILE_ID
|
||||
|
||||
/** Rows scanned when correlating a completed turn (newest-first page). */
|
||||
private const val TURN_ROW_SCAN_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Reads the durable REST transcript high-watermark for [sessionId] and arms a
|
||||
* boundary for it. Call immediately BEFORE submitting the prompt.
|
||||
*
|
||||
* Deliberately does not send a `profile` query param: the boundary ids are later
|
||||
* compared against rows hydrated by the chat screen, which fetches this
|
||||
* endpoint without a profile. Keeping both reads in the same namespace is what
|
||||
* makes the id comparison meaningful.
|
||||
*
|
||||
* Returns false on any failure (including a gateway that does not confirm
|
||||
* `order=latest`) — the caller sends its prompt anyway and the turn simply
|
||||
* becomes uncorrelatable.
|
||||
*/
|
||||
internal suspend fun captureTurnBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
timeoutMs: Long = TURN_BOUNDARY_TIMEOUT_MS,
|
||||
): Boolean {
|
||||
if (scopeId.isBlank() || sessionId.isBlank()) return false
|
||||
val maxMessageId =
|
||||
try {
|
||||
withTimeoutOrNull(timeoutMs) { fetchBoundaryTailId(sessionId) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Total by contract: this probe is best-effort identity for a
|
||||
// notification convenience and must NEVER fail the caller's turn.
|
||||
// Swallowing Throwable (not just Exception) is deliberate — a
|
||||
// gateway-layer Error (uninitialised client, NoClassDefFound) used to
|
||||
// escape past the caller's `catch (Exception)` and get thrown inside
|
||||
// a background scope, which aborted the send and leaked as an
|
||||
// uncaught exception attributed to an unrelated later test.
|
||||
null
|
||||
} ?: return false
|
||||
return TurnCorrelationTracker.armBoundary(scopeId, sessionId, maxMessageId) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest row id of the transcript, or null when the read cannot be trusted.
|
||||
*
|
||||
* `pagination` is only returned by gateways that honoured `order=latest`; a
|
||||
* backend that ignores the param would answer a `limit=1` probe with the OLDEST
|
||||
* row, producing a boundary that is too low — a lower bound that is too low is
|
||||
* exactly what lets a historical duplicate win. Absent proof, fail closed.
|
||||
*/
|
||||
private suspend fun fetchBoundaryTailId(sessionId: String): Int? {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = 1,
|
||||
offset = 0,
|
||||
includeCompacted = true,
|
||||
order = "latest",
|
||||
)
|
||||
}
|
||||
val body = (result as? NetworkResult.Success)?.data ?: return null
|
||||
// The whole safety property rests on this single row being the HIGHEST id in
|
||||
// the transcript. Require the gateway to confirm it honoured order=latest
|
||||
// (same proof the chat hydration uses) — a backend that ignores the param
|
||||
// answers with the OLDEST row, and a lower bound that is too low is exactly
|
||||
// what lets a historical duplicate win.
|
||||
if (body.pagination?.order != "latest") return null
|
||||
return body.messages.mapNotNull { it.id }.maxOrNull() ?: 0
|
||||
}
|
||||
|
||||
/** Newest-first REST page used to correlate a completed turn. */
|
||||
internal suspend fun fetchTurnRows(sessionId: String): List<SessionMessage>? {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = TURN_ROW_SCAN_LIMIT,
|
||||
offset = 0,
|
||||
includeCompacted = true,
|
||||
order = "latest",
|
||||
)
|
||||
}
|
||||
return (result as? NetworkResult.Success)?.data?.messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the exact REST row persisted by the turn that just completed, given
|
||||
* the boundary armed before that turn was submitted.
|
||||
*
|
||||
* The boundary is consumed either way in `finally`: the turn it describes is
|
||||
* over, and leaving it armed would let the next completion bind to a row above
|
||||
* a stale lower bound.
|
||||
*/
|
||||
internal suspend fun correlateCompletedTurnRow(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionText: String,
|
||||
resolver: TurnRowResolver = defaultTurnRowResolver,
|
||||
): Int? {
|
||||
val boundary = TurnCorrelationTracker.boundaryFor(scopeId, sessionId) ?: return null
|
||||
return try {
|
||||
resolver.resolve(sessionId, boundary, completionText)
|
||||
} finally {
|
||||
TurnCorrelationTracker.clearBoundary(scopeId, sessionId, boundary.generation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Production resolver — reads the newest REST page for the session. */
|
||||
internal val defaultTurnRowResolver =
|
||||
TurnRowResolver(fetchLatestRows = { sessionId -> fetchTurnRows(sessionId) })
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.ui.chat.HostMediaExtractor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Resolves the one persisted REST assistant row produced by a completed
|
||||
* mobile-originated turn.
|
||||
*
|
||||
* Design rules (all of them are load-bearing):
|
||||
* - Only rows strictly above [TurnBoundary.beforeMessageId] are candidates, so
|
||||
* historical duplicates can never win.
|
||||
* - The FULL completion text is compared, never the 100-character notification
|
||||
* preview.
|
||||
* - Exactly one candidate resolves. Zero candidates retry (the row may not be
|
||||
* persisted yet). More than one candidate is ambiguous and fails closed —
|
||||
* `firstOrNull`, `lastOrNull`, `maxBy` and "newest match" are all guessing.
|
||||
* - Never compares Android notification time with server timestamps.
|
||||
*/
|
||||
internal class TurnRowResolver(
|
||||
private val fetchLatestRows: suspend (String) -> List<SessionMessage>?,
|
||||
/** Attempt delays; the first attempt runs immediately. */
|
||||
private val retryDelaysMs: List<Long> = listOf(0L, 200L, 400L),
|
||||
) {
|
||||
suspend fun resolve(
|
||||
sessionId: String,
|
||||
boundary: TurnBoundary,
|
||||
completionText: String,
|
||||
): Int? {
|
||||
val wanted = assistantTextKey(completionText)
|
||||
if (wanted.isEmpty) return null
|
||||
if (sessionId.isBlank()) return null
|
||||
|
||||
for ((attempt, delayMs) in retryDelaysMs.withIndex()) {
|
||||
if (attempt > 0) delay(delayMs)
|
||||
val rows = fetchRows(sessionId) ?: continue
|
||||
val candidates =
|
||||
rows.mapNotNull { row ->
|
||||
val id = row.id ?: return@mapNotNull null
|
||||
if (id <= boundary.beforeMessageId) return@mapNotNull null
|
||||
if (!row.role.equals("assistant", ignoreCase = true)) return@mapNotNull null
|
||||
if (assistantTextKey(row.contentText) != wanted) return@mapNotNull null
|
||||
id
|
||||
}
|
||||
if (candidates.size == 1) return candidates.single()
|
||||
// Two rows above the boundary match this completion: the turn's own
|
||||
// row cannot be told apart from the other. Leave the notification
|
||||
// active rather than bind it to a coin flip.
|
||||
if (candidates.size > 1) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun fetchRows(sessionId: String): List<SessionMessage>? =
|
||||
try {
|
||||
fetchLatestRows(sessionId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized comparison key for an assistant row.
|
||||
*
|
||||
* Text replies compare their trimmed full text. `MEDIA:` replies compare the
|
||||
* directive-stripped text PLUS the extracted host paths, using the same
|
||||
* canonicalization the chat mapper applies before rendering — so a MEDIA row
|
||||
* and the matching completion agree on both halves, and a MEDIA-only reply
|
||||
* (which strips to an empty string) still compares by its paths instead of
|
||||
* degenerating into "any assistant row".
|
||||
*/
|
||||
internal data class AssistantTextKey(
|
||||
val text: String,
|
||||
val media: List<String>,
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = text.isBlank() && media.isEmpty()
|
||||
}
|
||||
|
||||
private const val MEDIA_DIRECTIVE = "MEDIA:"
|
||||
|
||||
internal fun assistantTextKey(raw: String): AssistantTextKey {
|
||||
val trimmed = raw.trim()
|
||||
if (!trimmed.contains(MEDIA_DIRECTIVE)) return AssistantTextKey(trimmed, emptyList())
|
||||
return AssistantTextKey(
|
||||
text = HostMediaExtractor.strip(trimmed).trim(),
|
||||
media = HostMediaExtractor.extract(trimmed).map { it.path },
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -72,6 +73,7 @@ fun AchievementsScreen(
|
||||
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -92,7 +94,8 @@ fun AchievementsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadAchievements()
|
||||
viewModel.loadRecentUnlocks()
|
||||
}
|
||||
@@ -129,7 +132,10 @@ fun AchievementsScreen(
|
||||
title = { Text(stringResource(R.string.screen_achievements)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAchievements() },
|
||||
onRefresh = {
|
||||
viewModel.loadAchievements(forceRefresh = true)
|
||||
viewModel.loadRecentUnlocks()
|
||||
},
|
||||
modifier = modifier,
|
||||
actions = {
|
||||
Box {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -46,9 +50,53 @@ class AchievementsViewModel :
|
||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val api get() = ApiClient.hermesApi
|
||||
private val achievementsCache = SwrCache<String, AchievementsResponse>()
|
||||
|
||||
fun loadAchievements() {
|
||||
safeLaunchLoad(
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = emptyList(),
|
||||
unlockedCount = 0,
|
||||
discoveredCount = 0,
|
||||
secretCount = 0,
|
||||
totalCount = 0,
|
||||
categories = emptyList(),
|
||||
recentUnlocks = emptyList(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAchievements(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = achievementsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
val categories =
|
||||
data.achievements
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = data.achievements.orEmpty(),
|
||||
unlockedCount = data.unlockedCount,
|
||||
discoveredCount = data.discoveredCount,
|
||||
secretCount = data.secretCount,
|
||||
totalCount = data.totalCount,
|
||||
isStale = data.isStale,
|
||||
generatedAt = data.generatedAt,
|
||||
scanState = data.scanMeta?.status?.state ?: "idle",
|
||||
scanLastError = data.scanMeta?.status?.lastError,
|
||||
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
|
||||
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
|
||||
categories = categories,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { api.getAchievements() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
@@ -100,6 +148,7 @@ class AchievementsViewModel :
|
||||
}
|
||||
|
||||
fun rescan() {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.rescanAchievements() } },
|
||||
onStart = {
|
||||
@@ -111,6 +160,12 @@ class AchievementsViewModel :
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
// A successful rescan is a mutation: publish the fresh payload into the
|
||||
// scoped cache, otherwise the next SWR revisit replays the pre-rescan list.
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope == requestScope) {
|
||||
achievementsCache.put(requestScope.scopedKey("default"), data)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRescanning = false,
|
||||
@@ -155,7 +210,7 @@ class AchievementsViewModel :
|
||||
toastMessage = "Achievement state reset",
|
||||
)
|
||||
}
|
||||
loadAchievements()
|
||||
loadAchievements(forceRefresh = true)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -235,68 +237,72 @@ class BotsViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
|
||||
var profilesWithMeta: List<ProfileInfo>? = null
|
||||
try {
|
||||
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
|
||||
val jsonElement =
|
||||
when (rpcResult) {
|
||||
is JsonElement -> rpcResult
|
||||
null -> null
|
||||
else -> rpcResult.toJsonElement()
|
||||
coroutineScope {
|
||||
val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
||||
|
||||
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
|
||||
var profilesWithMeta: List<ProfileInfo>? = null
|
||||
try {
|
||||
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
|
||||
val jsonElement =
|
||||
when (rpcResult) {
|
||||
is JsonElement -> rpcResult
|
||||
null -> null
|
||||
else -> rpcResult.toJsonElement()
|
||||
}
|
||||
if (jsonElement != null) {
|
||||
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
|
||||
if (!resp.profiles.isNullOrEmpty()) {
|
||||
profilesWithMeta = resp.profiles
|
||||
}
|
||||
}
|
||||
if (jsonElement != null) {
|
||||
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
|
||||
if (!resp.profiles.isNullOrEmpty()) {
|
||||
profilesWithMeta = resp.profiles
|
||||
} catch (_: Exception) {
|
||||
// Fallback to REST API below
|
||||
}
|
||||
|
||||
val profilesResult =
|
||||
if (profilesWithMeta != null) {
|
||||
null
|
||||
} else {
|
||||
safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fallback to REST API below
|
||||
}
|
||||
val activeResult = activeDeferred.await()
|
||||
|
||||
val profilesResult =
|
||||
if (profilesWithMeta != null) {
|
||||
null
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesWithMeta,
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else if (profilesResult is NetworkResult.Success) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesResult.data.profiles.orEmpty(),
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||
}
|
||||
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
|
||||
|
||||
if (profilesWithMeta != null) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesWithMeta,
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else if (profilesResult is NetworkResult.Success) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesResult.data.profiles.orEmpty(),
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val err =
|
||||
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||
?: "Failed to load bots"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = err,
|
||||
)
|
||||
val err =
|
||||
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||
?: "Failed to load bots"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,6 +696,11 @@ class GroupChatViewModel(
|
||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||
|
||||
try {
|
||||
// Bot-group turns are orchestrated across per-bot sessions that
|
||||
// are created and swapped mid-flight, so no clean pre-submit
|
||||
// turn boundary can be proven for them. They stay uncorrelated
|
||||
// (reply notifications from group turns are never auto-dismissed
|
||||
// from REST hydration) instead of borrowing another turn's bound.
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
|
||||
@@ -593,16 +593,20 @@ internal fun InlineAttachment(
|
||||
val isSaving = savingPath != null && savingPath == attachmentPath
|
||||
val isOpening = openingPath != null && openingPath == attachmentPath
|
||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||
if (attachment.isVideo) {
|
||||
var showVideoDialog by remember { mutableStateOf(false) }
|
||||
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
|
||||
videoUri = attachment.uri,
|
||||
onFullScreenClick = { showVideoDialog = true },
|
||||
if (attachment.isVideo || attachment.isAudio) {
|
||||
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
|
||||
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
|
||||
uri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onFullScreenClick = { showMediaDialog = true },
|
||||
)
|
||||
if (showVideoDialog) {
|
||||
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
|
||||
videoUri = attachment.uri,
|
||||
onDismissRequest = { showVideoDialog = false },
|
||||
if (showMediaDialog) {
|
||||
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
|
||||
mediaUri = attachment.gatewayUrl ?: attachment.uri,
|
||||
title = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
onDismissRequest = { showMediaDialog = false },
|
||||
)
|
||||
}
|
||||
} else if (attachment.isImage) {
|
||||
|
||||
@@ -24,7 +24,8 @@ object ChatInputPolicy {
|
||||
text: String,
|
||||
pendingAttachments: List<Any>,
|
||||
isConnected: Boolean,
|
||||
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected
|
||||
isSessionReady: Boolean,
|
||||
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected && isSessionReady
|
||||
|
||||
/**
|
||||
* Whether the input placeholder should read "queued" rather than the plain
|
||||
|
||||
@@ -85,6 +85,8 @@ data class ChatMessage(
|
||||
val tokenCount: Int? = null,
|
||||
/** Tokens per second generation speed (persisted to SQLite). */
|
||||
val tps: Double? = null,
|
||||
/** Stable completion identity correlating with reply notifications for read tracking. */
|
||||
val completionId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,14 +11,16 @@ import com.m57.hermescontrol.data.local.toUiModel
|
||||
* UI state management and WebSocket event handling.
|
||||
*/
|
||||
open class ChatPersistenceRepository(
|
||||
private val dao: ChatMessageDao,
|
||||
private val daoProvider: suspend () -> ChatMessageDao,
|
||||
) {
|
||||
constructor(dao: ChatMessageDao) : this({ dao })
|
||||
|
||||
/** Persist a single message for the given session. */
|
||||
suspend fun persistMessage(
|
||||
message: ChatMessage,
|
||||
sessionId: String,
|
||||
) {
|
||||
dao.upsert(message.toEntity(sessionId))
|
||||
daoProvider().upsert(message.toEntity(sessionId))
|
||||
}
|
||||
|
||||
/** Persist multiple messages in one transaction. */
|
||||
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
|
||||
sessionId: String,
|
||||
) {
|
||||
val entities = messages.map { it.toEntity(sessionId) }
|
||||
dao.upsertAll(entities)
|
||||
daoProvider().upsertAll(entities)
|
||||
}
|
||||
|
||||
/** Load cached messages for a session from Room. */
|
||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||
|
||||
/** Clear all cached messages for a session (e.g. after /undo rewind). */
|
||||
suspend fun clearMessagesForSession(sessionId: String) {
|
||||
dao.deleteMessagesForSession(sessionId)
|
||||
daoProvider().deleteMessagesForSession(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ fun ChatScreen(
|
||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
@@ -426,6 +427,20 @@ fun ChatScreen(
|
||||
context = context,
|
||||
)
|
||||
|
||||
val isChatContentReadable =
|
||||
!showReloginDialog &&
|
||||
!state.updateConfirmOpen &&
|
||||
!actionProgressState.visible &&
|
||||
!state.showModelPicker &&
|
||||
state.modelSwitchConfirmMessage == null &&
|
||||
state.sudoPrompt == null &&
|
||||
state.secretPrompt == null &&
|
||||
!(showContextSheet && state.contextBreakdown != null) &&
|
||||
!showSubagentInspectionSheet &&
|
||||
state.btwState == null &&
|
||||
viewingImage == null &&
|
||||
!connectorsState.isVisible
|
||||
|
||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
@@ -442,6 +457,7 @@ fun ChatScreen(
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = viewModel,
|
||||
isOverlayActive = !isChatContentReadable,
|
||||
)
|
||||
|
||||
HermesScaffold(
|
||||
@@ -503,22 +519,6 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Search toggle
|
||||
IconButton(onClick = { viewModel.toggleSearch() }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||
contentDescription =
|
||||
if (searchState.isActive) {
|
||||
stringResource(
|
||||
R.string.chat_action_close_search,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.chat_action_search)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { viewModel.createNewSession() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
@@ -542,6 +542,33 @@ fun ChatScreen(
|
||||
expanded = showSessionMenu,
|
||||
onDismissRequest = { showSessionMenu = false },
|
||||
) {
|
||||
// Chat search lives in this overflow menu (moved from the top bar)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (searchState.isActive) {
|
||||
R.string.chat_action_close_search
|
||||
} else {
|
||||
R.string.chat_action_search
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showSessionMenu = false
|
||||
viewModel.toggleSearch()
|
||||
},
|
||||
modifier = Modifier.testTag("chat_menu_search"),
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||
leadingIcon = {
|
||||
@@ -748,15 +775,18 @@ fun ChatScreen(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(inputFieldValue.text)
|
||||
inputFieldValue = TextFieldValue("")
|
||||
// Jump to bottom after send (serialized through the controller).
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
isListening = mediaLaunchers.isListening,
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isConnected = state.isConnected,
|
||||
isSessionReady = state.isSessionReady,
|
||||
sessionPreparationFailed = state.resumeError != null,
|
||||
commandCatalog = state.commandCatalog,
|
||||
slashUsageCounts = state.slashUsageCounts,
|
||||
pendingAttachments = state.pendingAttachments,
|
||||
@@ -776,6 +806,7 @@ fun ChatScreen(
|
||||
},
|
||||
// Composer toolbar wiring (PR 1)
|
||||
currentSessionModel = state.currentSessionModel,
|
||||
showModelProvider = state.showModelProvider,
|
||||
reasoningLevel = state.reasoningLevel,
|
||||
onModelTap = { viewModel.openModelPicker() },
|
||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
@@ -20,12 +21,93 @@ internal fun mapServerMessages(
|
||||
offset: Int,
|
||||
latestPaging: Boolean,
|
||||
liveMessages: List<ChatMessage>,
|
||||
isPagingOlder: Boolean = false,
|
||||
context: android.content.Context? = null,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
liveMessages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
val liveByExactId =
|
||||
liveMessages
|
||||
.filter { !it.completionId.isNullOrBlank() }
|
||||
.associateBy { it.id }
|
||||
|
||||
val unmappedLiveWsAssistants =
|
||||
if (isPagingOlder) {
|
||||
emptyList()
|
||||
} else {
|
||||
liveMessages
|
||||
.filter {
|
||||
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
|
||||
!it.completionId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
if (unmappedLiveWsAssistants.isNotEmpty()) {
|
||||
val remainingWs = unmappedLiveWsAssistants.toMutableList()
|
||||
for (i in messages.indices.reversed()) {
|
||||
if (remainingWs.isEmpty()) break
|
||||
val m = messages[i]
|
||||
val r =
|
||||
when (m.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
if (r != MessageRole.ASSISTANT) continue
|
||||
val rawContent = m.contentText
|
||||
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
|
||||
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
|
||||
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
|
||||
} else {
|
||||
"rest-$sessionId-${offset + i}"
|
||||
}
|
||||
if (liveByExactId.containsKey(restId)) continue
|
||||
|
||||
val canonicalContent =
|
||||
if (rawContent.contains("MEDIA:")) {
|
||||
HostMediaExtractor.strip(rawContent).trim()
|
||||
} else {
|
||||
rawContent.trim()
|
||||
}
|
||||
|
||||
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
|
||||
if (wsIdx >= 0) {
|
||||
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
|
||||
wsCompletionIdByRestIndex[i] = compId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST-only recovery is fail-closed: only a durable server row ID may
|
||||
// transfer notification identity after the WebSocket copy is gone.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
|
||||
if (
|
||||
activeTarget != null &&
|
||||
activeTarget.sessionId == sessionId &&
|
||||
activeTarget.serverMessageId != null &&
|
||||
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
|
||||
) {
|
||||
val exactIndex =
|
||||
messages.indexOfFirst { message ->
|
||||
message.id == activeTarget.serverMessageId &&
|
||||
message.role.equals("assistant", ignoreCase = true)
|
||||
}
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
|
||||
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
@@ -171,6 +253,12 @@ internal fun mapServerMessages(
|
||||
}
|
||||
}
|
||||
|
||||
val completionId =
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
@@ -184,6 +272,7 @@ internal fun mapServerMessages(
|
||||
isStreaming = false,
|
||||
displayKind = msg.display_kind,
|
||||
tokenCount = tokenCount,
|
||||
completionId = completionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.notification.captureTurnBoundary
|
||||
import com.m57.hermescontrol.notification.correlationScopeId
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -118,6 +120,7 @@ data class ChatUiState(
|
||||
val showUserMessageTokens: Boolean = true,
|
||||
val showAssistantMessageTokens: Boolean = true,
|
||||
val showTokensPerSecond: Boolean = true,
|
||||
val showModelProvider: Boolean = false,
|
||||
// Commands catalog
|
||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||
@@ -181,6 +184,7 @@ data class ChatUiState(
|
||||
/** Agent todo / plan items (issue #736). */
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
||||
val isSessionReady: Boolean = false,
|
||||
val resumeError: String? = null,
|
||||
val isResumeRetrying: Boolean = false,
|
||||
/** Text staged to prefill the composer (e.g. from /undo). */
|
||||
@@ -322,9 +326,9 @@ class ChatViewModel(
|
||||
application: Application,
|
||||
private val startCleanup: Boolean,
|
||||
repo: ChatPersistenceRepository =
|
||||
ChatPersistenceRepository(
|
||||
HermesDatabase.get(application).chatMessageDao(),
|
||||
),
|
||||
ChatPersistenceRepository {
|
||||
HermesDatabase.get(application).chatMessageDao()
|
||||
},
|
||||
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
@@ -587,6 +591,13 @@ class ChatViewModel(
|
||||
// fire with a stale id and 4001 "session not found";
|
||||
// handleGatewayReady rebinds it on the re-resume.
|
||||
runtimeSessionId = null
|
||||
resumedGeneration = -1L
|
||||
hydratedGeneration = -1L
|
||||
activeResumeRequestSequence = ++resumeRequestSequence
|
||||
activeHydrationRequestSequence = ++hydrationRequestSequence
|
||||
// Preserve metadata so late results and errors are rejected as stale.
|
||||
sessionGeneration++
|
||||
_uiState.update { it.copy(isSessionReady = false) }
|
||||
// Fail any in-flight awaited RPCs so callers don't hang
|
||||
// across the disconnect (delegated to HermesWsClient, issue #526).
|
||||
wsClient.rejectAllPending()
|
||||
@@ -766,43 +777,7 @@ class ChatViewModel(
|
||||
_streamingState.update { result.streamingState }
|
||||
|
||||
// Process side-effects from the reducer
|
||||
for (effect in result.effects) {
|
||||
when (effect) {
|
||||
is ReducerEffect.PersistMessage -> {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
repo.persistMessage(effect.message, effect.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
is ReducerEffect.CreateNewSession -> {
|
||||
createNewSession()
|
||||
}
|
||||
|
||||
is ReducerEffect.LoadSessions -> {
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is ReducerEffect.RefreshSessions -> {
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is ReducerEffect.RefreshContextUsage -> {
|
||||
// Streaming finished — refresh the context meter now rather
|
||||
// than waiting up to 5s for the next session-sync poll.
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
|
||||
is ReducerEffect.AttachHostMedia -> {
|
||||
// Issue #724: turn host-path MEDIA: directives into real
|
||||
// attachments (images inline, every other file tappable)
|
||||
// via the gateway /api/files/download endpoint. Works on a
|
||||
// remote phone too.
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatchReducerEffects(result.effects)
|
||||
|
||||
// Handle complex events that need ViewModel-specific context
|
||||
when (event) {
|
||||
@@ -811,84 +786,7 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
is WsEvent.SessionInfo -> {
|
||||
// Session info pushed by backend when config changes
|
||||
// (model switch, reasoning level, etc.)
|
||||
val info = event.data
|
||||
if (info != null) {
|
||||
val model = info["model"] as? String
|
||||
val provider = info["provider"] as? String
|
||||
val reasoningEffort = info["reasoning_effort"] as? String
|
||||
val terminalBackend = info["terminal_backend"] as? String
|
||||
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
|
||||
val fastFlag =
|
||||
(info["fast"] as? Boolean)
|
||||
?: (if (serviceTier != null) serviceTier == "priority" else null)
|
||||
val newModelLabel =
|
||||
if (model != null && provider != null) {
|
||||
"$provider/$model"
|
||||
} else {
|
||||
model
|
||||
}
|
||||
// Issue #817 & #1103: on a REAL model swap the meter's denominator
|
||||
// still belongs to the old model until the next fetch.
|
||||
// Check against both lastConfirmedSessionModel and optimisticPreviousModel
|
||||
// so optimistic model updates in sendSlashModel don't defeat swap detection.
|
||||
val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
|
||||
val previousModel =
|
||||
lastConfirmedSessionModel
|
||||
?: optimisticPrevious
|
||||
?: _uiState.value.currentSessionModel
|
||||
val modelSwapped =
|
||||
previousModel != null &&
|
||||
newModelLabel != null &&
|
||||
!newModelLabel.equals(previousModel, ignoreCase = true)
|
||||
val initialHydration = previousModel == null && newModelLabel != null
|
||||
val meterEmpty = _uiState.value.fullContextTokens == null
|
||||
lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel
|
||||
if (newModelLabel != null) {
|
||||
modelSwitchDelegate.onModelConfirmed(newModelLabel)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
||||
reasoningLevel =
|
||||
if (reasoningEffort.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
reasoningEffort
|
||||
},
|
||||
fastMode = fastFlag ?: state.fastMode,
|
||||
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
|
||||
terminalBackend = terminalBackend ?: state.terminalBackend,
|
||||
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
||||
)
|
||||
}
|
||||
modelSwitchDelegate.syncCurrentModelCapabilities()
|
||||
if (modelSwapped) {
|
||||
modelGeneration++
|
||||
contextUsageJob?.cancel()
|
||||
contextUsageJob = null
|
||||
// Issue #817 & #1103: after a swap the REST model/info window is
|
||||
// PROFILE-scoped and may describe the old model (e.g. a
|
||||
// session-scoped swap) — the meter must not fall back to
|
||||
// it. Wait for the RPC's live context_max instead; the
|
||||
// chip stays hidden until the real window lands.
|
||||
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
|
||||
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
|
||||
contextUsageJob?.isActive != true
|
||||
) {
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
// Session.info can carry `pending_approval` (reconnect
|
||||
// reconciliation) — surface it unless already on screen.
|
||||
val pendingApproval = info["pending_approval"] as? Map<*, *>
|
||||
if (pendingApproval != null) {
|
||||
approvalsDelegate.maybeSurfacePendingApproval(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
handleSessionInfo(event.data)
|
||||
}
|
||||
|
||||
is WsEvent.MessageToken -> {
|
||||
@@ -1231,6 +1129,126 @@ class ChatViewModel(
|
||||
return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId
|
||||
}
|
||||
|
||||
private fun dispatchReducerEffects(effects: List<ReducerEffect>) {
|
||||
for (effect in effects) {
|
||||
when (effect) {
|
||||
is ReducerEffect.PersistMessage -> {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
repo.persistMessage(effect.message, effect.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
is ReducerEffect.CreateNewSession -> {
|
||||
createNewSession()
|
||||
}
|
||||
|
||||
is ReducerEffect.LoadSessions -> {
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is ReducerEffect.RefreshSessions -> {
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is ReducerEffect.RefreshContextUsage -> {
|
||||
// Streaming finished — refresh the context meter now rather
|
||||
// than waiting up to 5s for the next session-sync poll.
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
|
||||
is ReducerEffect.AttachHostMedia -> {
|
||||
// Issue #724: turn host-path MEDIA: directives into real
|
||||
// attachments (images inline, every other file tappable)
|
||||
// via the gateway /api/files/download endpoint. Works on a
|
||||
// remote phone too.
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionInfo(info: Map<String, Any?>?) {
|
||||
// Session info pushed by backend when config changes
|
||||
// (model switch, reasoning level, etc.)
|
||||
if (info != null) {
|
||||
val model = info["model"] as? String
|
||||
val provider = info["provider"] as? String
|
||||
val reasoningEffort = info["reasoning_effort"] as? String
|
||||
val terminalBackend = info["terminal_backend"] as? String
|
||||
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
|
||||
val fastFlag =
|
||||
(info["fast"] as? Boolean)
|
||||
?: (if (serviceTier != null) serviceTier == "priority" else null)
|
||||
val newModelLabel =
|
||||
if (model != null && provider != null) {
|
||||
"$provider/$model"
|
||||
} else {
|
||||
model
|
||||
}
|
||||
// Issue #817 & #1103: on a REAL model swap the meter's denominator
|
||||
// still belongs to the old model until the next fetch.
|
||||
// Check against both lastConfirmedSessionModel and optimisticPreviousModel
|
||||
// so optimistic model updates in sendSlashModel don't defeat swap detection.
|
||||
val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
|
||||
val previousModel =
|
||||
lastConfirmedSessionModel
|
||||
?: optimisticPrevious
|
||||
?: _uiState.value.currentSessionModel
|
||||
val modelSwapped =
|
||||
previousModel != null &&
|
||||
newModelLabel != null &&
|
||||
!newModelLabel.equals(previousModel, ignoreCase = true)
|
||||
val initialHydration = previousModel == null && newModelLabel != null
|
||||
val meterEmpty = _uiState.value.fullContextTokens == null
|
||||
lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel
|
||||
if (newModelLabel != null) {
|
||||
modelSwitchDelegate.onModelConfirmed(newModelLabel)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
||||
reasoningLevel =
|
||||
if (reasoningEffort.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
reasoningEffort
|
||||
},
|
||||
fastMode = fastFlag ?: state.fastMode,
|
||||
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
|
||||
terminalBackend = terminalBackend ?: state.terminalBackend,
|
||||
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
||||
)
|
||||
}
|
||||
modelSwitchDelegate.syncCurrentModelCapabilities()
|
||||
if (modelSwapped) {
|
||||
modelGeneration++
|
||||
contextUsageJob?.cancel()
|
||||
contextUsageJob = null
|
||||
// Issue #817 & #1103: after a swap the REST model/info window is
|
||||
// PROFILE-scoped and may describe the old model (e.g. a
|
||||
// session-scoped swap) — the meter must not fall back to
|
||||
// it. Wait for the RPC's live context_max instead; the
|
||||
// chip stays hidden until the real window lands.
|
||||
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
|
||||
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
|
||||
contextUsageJob?.isActive != true
|
||||
) {
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
// Session.info can carry `pending_approval` (reconnect
|
||||
// reconciliation) — surface it unless already on screen.
|
||||
val pendingApproval = info["pending_approval"] as? Map<*, *>
|
||||
if (pendingApproval != null) {
|
||||
approvalsDelegate.maybeSurfacePendingApproval(
|
||||
pendingApproval,
|
||||
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC response handling ────────────────────────────────────────────
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -1243,8 +1261,14 @@ class ChatViewModel(
|
||||
if (request != null && isStaleSessionRequest(request)) return
|
||||
when (method) {
|
||||
WsMethods.SESSION_CREATE -> {
|
||||
val resultMap = result as? Map<String, Any?> ?: return
|
||||
val runtimeId = resultMap["session_id"] as? String ?: return
|
||||
val resultMap = result as? Map<String, Any?>
|
||||
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
if (runtimeId == null) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, resumeError = "Invalid session creation response")
|
||||
}
|
||||
return
|
||||
}
|
||||
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
|
||||
runtimeSessionId = runtimeId
|
||||
// The gateway persists the row lazily on the first prompt —
|
||||
@@ -1254,6 +1278,7 @@ class ChatViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentSessionId = storageId,
|
||||
isSessionReady = true,
|
||||
isLoading = false,
|
||||
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
|
||||
chatTitle = "Hermes",
|
||||
@@ -1301,7 +1326,7 @@ class ChatViewModel(
|
||||
// must stay the storage key — storing the runtime id here made
|
||||
// every later resume 4007 "session not found" (the DB lookup
|
||||
// misses) and the REST transcript 404.
|
||||
val runtimeId = resultMap["session_id"] as? String ?: return
|
||||
val runtimeId = (resultMap["session_id"] as? String)?.takeIf { it.isNotBlank() } ?: return
|
||||
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
|
||||
val generation =
|
||||
resetSessionState(
|
||||
@@ -1310,6 +1335,7 @@ class ChatViewModel(
|
||||
isLoading = false,
|
||||
)
|
||||
runtimeSessionId = runtimeId
|
||||
resumedGeneration = generation
|
||||
ActiveSessionHolder.set(runtimeId, storageId)
|
||||
sessionHasServerPresence = false
|
||||
sessionGoneRecoveryInFlight = false
|
||||
@@ -1341,16 +1367,22 @@ class ChatViewModel(
|
||||
|
||||
WsMethods.SESSION_RESUME -> {
|
||||
val resultMap = result as? Map<String, Any?>
|
||||
runtimeSessionId = resultMap?.get("session_id") as? String
|
||||
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
if (runtimeId == null) {
|
||||
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
|
||||
return
|
||||
}
|
||||
runtimeSessionId = runtimeId
|
||||
// Resume succeeded — the gateway confirmed the DB row.
|
||||
sessionHasServerPresence = true
|
||||
val sessionId =
|
||||
request?.sessionId
|
||||
?: (resultMap?.get("resumed") as? String)
|
||||
?: (resultMap["resumed"] as? String)
|
||||
?: _uiState.value.currentSessionId
|
||||
|
||||
// Parse session info from backend — model, provider, reasoning_effort
|
||||
val infoMap = resultMap?.get("info") as? Map<String, Any?>
|
||||
val infoMap = resultMap["info"] as? Map<String, Any?>
|
||||
val model = infoMap?.get("model") as? String
|
||||
val provider = infoMap?.get("provider") as? String
|
||||
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
|
||||
@@ -1416,7 +1448,7 @@ class ChatViewModel(
|
||||
// Reconnect replay: resume payload can carry `pending_approval`
|
||||
// (server `_session_info_payload`); surface it, then ask for
|
||||
// the full queue in case more are parked.
|
||||
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
|
||||
val pendingApproval = resultMap["pending_approval"] as? Map<*, *>
|
||||
if (pendingApproval != null) {
|
||||
approvalsDelegate.maybeSurfacePendingApproval(
|
||||
pendingApproval,
|
||||
@@ -1575,6 +1607,7 @@ class ChatViewModel(
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error ($method): $errorMsg",
|
||||
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1596,8 +1629,9 @@ class ChatViewModel(
|
||||
* 6. For each file → await `file.attach` (requires session_id), collect @file: refs
|
||||
* 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
|
||||
*/
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
|
||||
fun sendMessage(text: String): Boolean {
|
||||
if (!canSubmitMessage()) return false
|
||||
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
|
||||
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.startsWith("/", ignoreCase = true)) {
|
||||
@@ -1605,10 +1639,10 @@ class ChatViewModel(
|
||||
// of requiring the user to hand-type the provider/model.
|
||||
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
|
||||
openModelPicker()
|
||||
return
|
||||
return true
|
||||
}
|
||||
handleSlashCommand(trimmed)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
val oversizedAttachment =
|
||||
@@ -1622,7 +1656,7 @@ class ChatViewModel(
|
||||
composerTextToRestore = text,
|
||||
)
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Snapshot + clear attachments so the input bar empties immediately
|
||||
@@ -1654,7 +1688,7 @@ class ChatViewModel(
|
||||
// Issue #969: Session creation is still in-flight. Hold the prompt
|
||||
// so it is dispatched automatically the moment SESSION_CREATE lands.
|
||||
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
dispatchPrompt(
|
||||
@@ -1665,8 +1699,23 @@ class ChatViewModel(
|
||||
agentSessionId = agentSessionId,
|
||||
userMessage = userMessage,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun canSubmitMessage(): Boolean =
|
||||
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
||||
(
|
||||
(_uiState.value.isSessionReady && runtimeSessionId != null) ||
|
||||
(
|
||||
_uiState.value.currentSessionId == null &&
|
||||
pendingInitialPrompt == null &&
|
||||
idToMethod.any { (id, method) ->
|
||||
method == WsMethods.SESSION_CREATE &&
|
||||
sessionRequestById[id]?.let { !isStaleSessionRequest(it) } == true
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
private fun dispatchPrompt(
|
||||
text: String,
|
||||
attachments: List<Attachment>,
|
||||
@@ -1832,6 +1881,15 @@ class ChatViewModel(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Arm the durable turn boundary BEFORE prompt.submit leaves
|
||||
// the device: the REST high-watermark has to be read while
|
||||
// the reply row cannot exist yet. A failed capture is
|
||||
// non-fatal — the prompt is still sent, the turn just
|
||||
// becomes uncorrelatable and its reply notification is never
|
||||
// auto-dismissed from hydration instead of being bound to a
|
||||
// guessed row.
|
||||
prepareTurnCorrelation(storageSessionId)
|
||||
if (dispatchGeneration != sessionGeneration) return@launch
|
||||
wsClient.sendMessage(
|
||||
agentSessionId,
|
||||
fullText,
|
||||
@@ -1851,6 +1909,17 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms the reply-notification turn boundary for a prompt about to be
|
||||
* submitted. Best-effort by design: sending chat never depends on REST
|
||||
* health, so a failed read only makes this turn's reply notification
|
||||
* uncorrelatable — it stays active instead of being dismissed by a guess.
|
||||
*/
|
||||
private suspend fun prepareTurnCorrelation(storageSessionId: String?) {
|
||||
if (storageSessionId.isNullOrBlank()) return
|
||||
captureTurnBoundary(scopeId = correlationScopeId(), sessionId = storageSessionId)
|
||||
}
|
||||
|
||||
private fun captureTurnUsageBaselineIfNeeded() {
|
||||
if (_streamingState.value.turnUsageBaselineCaptured) return
|
||||
_streamingState.update {
|
||||
@@ -2307,8 +2376,15 @@ class ChatViewModel(
|
||||
) {
|
||||
if (text.isBlank()) return
|
||||
val sessionId = runtimeSessionId ?: return
|
||||
val storageSessionId = _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(isAgentTyping = true) }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// A queued prompt runs behind a turn that is already in flight, so
|
||||
// no clean lower bound exists for it (see /queue). It stays
|
||||
// uncorrelated rather than borrowing the running turn's boundary.
|
||||
if (!queued) {
|
||||
prepareTurnCorrelation(storageSessionId)
|
||||
}
|
||||
wsClient.sendMessage(
|
||||
sessionId,
|
||||
text,
|
||||
@@ -2559,6 +2635,7 @@ class ChatViewModel(
|
||||
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
|
||||
showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(),
|
||||
showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(),
|
||||
showModelProvider = AuthManager.isModelProviderShown(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2730,6 +2807,7 @@ class ChatViewModel(
|
||||
serverOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
context = getApplication(),
|
||||
)
|
||||
loadedMessageOffset = serverOffset
|
||||
withContext(ioDispatcher) {
|
||||
@@ -2860,6 +2938,7 @@ class ChatViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
messages = emptyList(),
|
||||
isSessionReady = false,
|
||||
currentSessionId = sessionId,
|
||||
chatTitle = title,
|
||||
isAgentTyping = false,
|
||||
@@ -2906,6 +2985,9 @@ class ChatViewModel(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
resumedGeneration = -1L
|
||||
hydratedGeneration = -1L
|
||||
_uiState.update { it.copy(isSessionReady = false) }
|
||||
val requestSequence = ++resumeRequestSequence
|
||||
activeResumeRequestSequence = requestSequence
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
@@ -2956,6 +3038,7 @@ class ChatViewModel(
|
||||
isResumeRetrying = false,
|
||||
resumeError = null,
|
||||
errorMessage = null,
|
||||
isSessionReady = runtimeSessionId != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3030,7 +3113,7 @@ class ChatViewModel(
|
||||
) {
|
||||
// Only handle if still on this session.
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
_uiState.update { it.copy(errorMessage = null, isSessionReady = false) }
|
||||
|
||||
// New session → reset the counter for a fresh backoff cycle.
|
||||
if (resumeRetrySessionId != sessionId) {
|
||||
@@ -3101,7 +3184,11 @@ class ChatViewModel(
|
||||
* resumeSession: reconnect / reselect / Retry all reset the counter).
|
||||
*/
|
||||
fun retryResumeSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId == null) {
|
||||
createNewSession()
|
||||
return
|
||||
}
|
||||
val generation = sessionGeneration
|
||||
cancelResumeRetry()
|
||||
_uiState.update {
|
||||
@@ -3154,6 +3241,7 @@ class ChatViewModel(
|
||||
returnedOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
loadedMessageOffset = returnedOffset
|
||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||
@@ -3238,6 +3326,7 @@ class ChatViewModel(
|
||||
nextOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||
|
||||
@@ -429,9 +429,38 @@ object ChatWsEventReducer {
|
||||
// above before this return because the final snapshot is still needed
|
||||
// as the next turn's cumulative baseline.
|
||||
if (text.isBlank()) {
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
var updatedTargetMsg: ChatMessage? = null
|
||||
val updatedMessages =
|
||||
if (!event.completionId.isNullOrBlank()) {
|
||||
val lastAssistantIdx =
|
||||
usageState.messages
|
||||
.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT &&
|
||||
(streamingState.sealedOrphanIds.contains(it.id) || it.id == streaming?.id)
|
||||
}.takeIf { it >= 0 }
|
||||
if (lastAssistantIdx != null) {
|
||||
usageState.messages.toMutableList().also { list ->
|
||||
val updated = list[lastAssistantIdx].copy(completionId = event.completionId)
|
||||
list[lastAssistantIdx] = updated
|
||||
updatedTargetMsg = updated
|
||||
}
|
||||
} else {
|
||||
usageState.messages
|
||||
}
|
||||
} else {
|
||||
usageState.messages
|
||||
}
|
||||
if (sid != null && updatedTargetMsg != null) {
|
||||
effects.add(ReducerEffect.PersistMessage(updatedTargetMsg, sid))
|
||||
}
|
||||
effects.add(ReducerEffect.RefreshSessions)
|
||||
effects.add(ReducerEffect.RefreshContextUsage)
|
||||
return ReducerResult(
|
||||
state = usageState.copy(isAgentTyping = false),
|
||||
state = usageState.copy(messages = updatedMessages, isAgentTyping = false),
|
||||
streamingState = StreamingState(),
|
||||
effects = effects,
|
||||
)
|
||||
}
|
||||
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
|
||||
@@ -449,6 +478,7 @@ object ChatWsEventReducer {
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
) ?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
@@ -456,6 +486,7 @@ object ChatWsEventReducer {
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
@@ -639,12 +670,7 @@ object ChatWsEventReducer {
|
||||
OkHttpProvider.json.encodeToString(it.toJsonElement())
|
||||
} ?: ""
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
val toolIdx = findToolIndex(messages, event.name, event.data?.get("tool_id") as? String, ToolStatus.RUNNING)
|
||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
val updated =
|
||||
@@ -675,10 +701,10 @@ object ChatWsEventReducer {
|
||||
/**
|
||||
* Attaches [ToolOutputRiskData] to the matching tool message.
|
||||
*
|
||||
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
|
||||
* If no RUNNING message exists (tool already completed), falls back to the last
|
||||
* COMPLETED tool with that name. This covers the case where the risk event arrives
|
||||
* after tool.complete.
|
||||
* Finds the matching stable tool ID first. If the event has no ID or no ID
|
||||
* match exists, it falls back to the tool name for legacy payloads. It
|
||||
* prefers RUNNING tools and then falls back to COMPLETED tools because the
|
||||
* risk event may arrive after tool.complete.
|
||||
*/
|
||||
private fun onToolOutputRisk(
|
||||
state: ChatUiState,
|
||||
@@ -694,20 +720,11 @@ object ChatWsEventReducer {
|
||||
|
||||
val messages = state.messages.toMutableList()
|
||||
|
||||
// Prefer RUNNING tool, fall back to COMPLETED
|
||||
var toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
// Prefer RUNNING tool, fall back to COMPLETED. Stable tool IDs win over
|
||||
// name matching so overlapping calls to the same tool stay isolated.
|
||||
var toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||
if (toolIdx < 0) {
|
||||
toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.COMPLETED
|
||||
}
|
||||
toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.COMPLETED)
|
||||
}
|
||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
@@ -904,12 +921,7 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.ToolProgress,
|
||||
): ReducerResult {
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
|
||||
@@ -925,12 +937,7 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.ToolGenerating,
|
||||
): ReducerResult {
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
|
||||
@@ -940,6 +947,30 @@ object ChatWsEventReducer {
|
||||
)
|
||||
}
|
||||
|
||||
/** Match stable tool IDs first, with name matching for legacy events. */
|
||||
private fun findToolIndex(
|
||||
messages: List<ChatMessage>,
|
||||
name: String?,
|
||||
toolId: String?,
|
||||
status: ToolStatus,
|
||||
): Int {
|
||||
if (!toolId.isNullOrBlank()) {
|
||||
val byId =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolCallId == toolId &&
|
||||
it.toolStatus == status
|
||||
}
|
||||
if (byId >= 0) return byId
|
||||
}
|
||||
|
||||
return messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == name &&
|
||||
it.toolStatus == status
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubagentEvent(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
enum class MediaSaveStrategy {
|
||||
MEDIA_STORE,
|
||||
CREATE_DOCUMENT,
|
||||
}
|
||||
|
||||
fun mediaSaveStrategy(sdkInt: Int = Build.VERSION.SDK_INT): MediaSaveStrategy =
|
||||
if (sdkInt >= Build.VERSION_CODES.Q) {
|
||||
MediaSaveStrategy.MEDIA_STORE
|
||||
} else {
|
||||
MediaSaveStrategy.CREATE_DOCUMENT
|
||||
}
|
||||
|
||||
object MediaExportHelper {
|
||||
private const val SHARE_CACHE_TTL_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveDisplayName(
|
||||
displayName: String?,
|
||||
mimeType: String?,
|
||||
uri: String,
|
||||
): String {
|
||||
val fallbackCandidate = mediaNameFromPath(uri).takeIf { it.isNotBlank() && it != "file" } ?: "hermes-media"
|
||||
val candidate = displayName?.takeIf { it.isNotBlank() } ?: fallbackCandidate
|
||||
val leaf = candidate.split('/', '\\').lastOrNull { it.isNotBlank() } ?: "hermes-media"
|
||||
val rawExt = extractFileExtension(leaf)
|
||||
val hasValidExt = rawExt.isNotBlank() && rawExt.length <= 10
|
||||
val finalExt =
|
||||
if (hasValidExt) {
|
||||
rawExt
|
||||
} else {
|
||||
mimeType?.let { extensionForMime(it) } ?: "bin"
|
||||
}
|
||||
val rawBase = if (hasValidExt) leaf.substringBeforeLast('.') else leaf
|
||||
val cleanedBase =
|
||||
rawBase
|
||||
.replace(Regex("[^A-Za-z0-9_\\-]"), "_")
|
||||
.trim('_')
|
||||
.take(60)
|
||||
.ifBlank { "hermes-media" }
|
||||
val cleanedExt =
|
||||
finalExt
|
||||
.replace(Regex("[^A-Za-z0-9]"), "")
|
||||
.take(10)
|
||||
.ifBlank { "bin" }
|
||||
return "$cleanedBase.$cleanedExt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cleanup of stale share directories older than 24 hours.
|
||||
* Only touches subdirectories inside [sharedMediaDir].
|
||||
*/
|
||||
internal fun sweepOldShareFiles(
|
||||
sharedMediaDir: File,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val cutoff = now - SHARE_CACHE_TTL_MS
|
||||
runCatching {
|
||||
sharedMediaDir.listFiles()?.forEach { entry ->
|
||||
if (entry.isDirectory) {
|
||||
if (entry.lastModified() < cutoff) {
|
||||
entry.deleteRecursively()
|
||||
}
|
||||
} else if (entry.isFile && entry.lastModified() < cutoff) {
|
||||
entry.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
internal fun createMediaStoreValues(
|
||||
displayName: String,
|
||||
mimeType: String,
|
||||
length: Long?,
|
||||
): ContentValues =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||
if (length != null && length > 0) {
|
||||
put(MediaStore.MediaColumns.SIZE, length)
|
||||
}
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams media directly to [MediaStore.Downloads] on API 29+ (Android Q+).
|
||||
* Uses bounded 64 KiB chunks with periodic coroutine cancellation checks.
|
||||
* Marked pending during write and un-pended on commit.
|
||||
* Guarantees that any partial item is deleted if the write fails or is canceled.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
suspend fun saveMediaToDownloads(
|
||||
context: Context,
|
||||
uri: String,
|
||||
fallbackMime: String = "application/octet-stream",
|
||||
displayName: String? = null,
|
||||
currentSdk: Int = Build.VERSION.SDK_INT,
|
||||
valuesFactory: (String, String, Long?) -> ContentValues = ::createMediaStoreValues,
|
||||
): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (currentSdk < Build.VERSION_CODES.Q) {
|
||||
throw UnsupportedOperationException("Direct MediaStore.Downloads requires API 29+")
|
||||
}
|
||||
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
|
||||
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
|
||||
val frameworkMime = normalizeFrameworkMime(streamInfo.mimeType)
|
||||
val values = valuesFactory(finalName, frameworkMime, streamInfo.length)
|
||||
val resolver = context.contentResolver
|
||||
val targetUri =
|
||||
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IOException("Failed to insert into MediaStore.Downloads")
|
||||
|
||||
var success = false
|
||||
try {
|
||||
resolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||
GatewayFileClient.copyChunked(inputStream, outputStream)
|
||||
} ?: throw IOException("Failed to open MediaStore output stream")
|
||||
|
||||
val publishValues = valuesFactory(finalName, frameworkMime, streamInfo.length)
|
||||
publishValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
val updated = resolver.update(targetUri, publishValues, null, null)
|
||||
if (updated <= 0) {
|
||||
throw IOException("Failed to publish media item in MediaStore")
|
||||
}
|
||||
success = true
|
||||
context.getString(R.string.media_player_saved)
|
||||
} finally {
|
||||
if (!success) {
|
||||
runCatching { resolver.delete(targetUri, null, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams media directly into a user-selected [targetUri] obtained via SAF (e.g. CreateDocument).
|
||||
* Used for API 26-28 where MediaStore.Downloads is unavailable.
|
||||
* Deletes partial documents if the stream write fails or is canceled.
|
||||
*/
|
||||
suspend fun saveMediaToUri(
|
||||
context: Context,
|
||||
sourceUri: String,
|
||||
targetUri: Uri,
|
||||
fallbackMime: String = "application/octet-stream",
|
||||
): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val resolver = context.contentResolver
|
||||
var success = false
|
||||
try {
|
||||
MediaStreamResolver.useStream(context, sourceUri, fallbackMime) { inputStream, _ ->
|
||||
resolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||
GatewayFileClient.copyChunked(inputStream, outputStream)
|
||||
} ?: throw IOException("Failed to open target document output stream")
|
||||
}
|
||||
success = true
|
||||
context.getString(R.string.media_player_saved)
|
||||
} finally {
|
||||
if (!success) {
|
||||
runCatching { DocumentsContract.deleteDocument(resolver, targetUri) }
|
||||
.onFailure {
|
||||
runCatching { resolver.delete(targetUri, null, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams media into an internal cache file in `shared_media/<uuid>/<safeName>` and builds
|
||||
* an ACTION_SEND Intent. Uses collision-proof session subdirectories and cleans stale entries
|
||||
* older than 24 hours. Share files are preserved so receiving apps can read them.
|
||||
*/
|
||||
suspend fun shareMedia(
|
||||
context: Context,
|
||||
uri: String,
|
||||
fallbackMime: String = "application/octet-stream",
|
||||
displayName: String? = null,
|
||||
fileUriProvider: (Context, File) -> Uri = { ctx, f ->
|
||||
FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", f)
|
||||
},
|
||||
intentFactory: (Uri, String) -> Intent = { contentUri, mime ->
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = mime
|
||||
putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
},
|
||||
): Intent? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
|
||||
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
|
||||
val sharedMediaDir = File(context.cacheDir, "shared_media").also { it.mkdirs() }
|
||||
sweepOldShareFiles(sharedMediaDir)
|
||||
|
||||
val sessionDir = File(sharedMediaDir, UUID.randomUUID().toString()).also { it.mkdirs() }
|
||||
val targetFile = File(sessionDir, finalName)
|
||||
|
||||
var success = false
|
||||
try {
|
||||
targetFile.outputStream().use { outputStream ->
|
||||
GatewayFileClient.copyChunked(inputStream, outputStream)
|
||||
}
|
||||
success = true
|
||||
} finally {
|
||||
if (!success) {
|
||||
targetFile.delete()
|
||||
sessionDir.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val contentUri = fileUriProvider(context, targetFile)
|
||||
val normalizedMime = normalizeFrameworkMime(streamInfo.mimeType)
|
||||
intentFactory(contentUri, normalizedMime)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
object MediaStreamResolver {
|
||||
data class StreamInfo(
|
||||
val mimeType: String,
|
||||
val extension: String,
|
||||
val length: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Streams characters directly from a [CharSequence] without allocating substring copies.
|
||||
* Skips whitespace to tolerate formatted Base64 data URLs.
|
||||
*/
|
||||
internal class CharSequenceInputStream(
|
||||
private val value: CharSequence,
|
||||
startIndex: Int,
|
||||
) : InputStream() {
|
||||
private var index = startIndex
|
||||
|
||||
override fun read(): Int {
|
||||
while (index < value.length) {
|
||||
val c = value[index++]
|
||||
if (!c.isWhitespace()) {
|
||||
return c.code and 0xFF
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int {
|
||||
if (len == 0) return 0
|
||||
var bytesRead = 0
|
||||
while (bytesRead < len) {
|
||||
val c = read()
|
||||
if (c == -1) {
|
||||
return if (bytesRead == 0) -1 else bytesRead
|
||||
}
|
||||
b[off + bytesRead] = c.toByte()
|
||||
bytesRead++
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the media source identified by [uriString] and streams its content
|
||||
* into [consumer]. Guarantees that all underlying streams and HTTP response bodies
|
||||
* are strictly closed via [use], and that no whole-media buffering occurs.
|
||||
*/
|
||||
suspend fun <T> useStream(
|
||||
context: Context,
|
||||
uriString: String,
|
||||
fallbackMime: String = "application/octet-stream",
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
when {
|
||||
uriString.startsWith("data:", ignoreCase = true) -> {
|
||||
openDataUrlStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("content://", ignoreCase = true) -> {
|
||||
openContentStream(context, uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("file:", ignoreCase = true) -> {
|
||||
openFileUriStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("/") -> {
|
||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("https://", ignoreCase = true) ||
|
||||
uriString.startsWith("http://", ignoreCase = true) -> {
|
||||
openHttpStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IOException("Unsupported media source: $uriString")
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> openDataUrlStream(
|
||||
dataUrl: String,
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) throw IOException("Malformed data URL")
|
||||
val meta = dataUrl.substring(0, comma)
|
||||
val mime =
|
||||
meta
|
||||
.substringAfter("data:", fallbackMime)
|
||||
.substringBefore(";")
|
||||
.ifBlank { fallbackMime }
|
||||
if (!meta.contains(";base64", ignoreCase = true)) {
|
||||
throw IOException("Only base64 data URLs are supported")
|
||||
}
|
||||
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
|
||||
val decodingStream = Base64.getDecoder().wrap(rawStream)
|
||||
return decodingStream.use { stream ->
|
||||
consumer(
|
||||
stream,
|
||||
StreamInfo(
|
||||
mimeType = mime,
|
||||
extension = extensionForMime(mime),
|
||||
length = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> openContentStream(
|
||||
context: Context,
|
||||
uriString: String,
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val uri = Uri.parse(uriString)
|
||||
val resolvedMime = runCatching { context.contentResolver.getType(uri) }.getOrNull()
|
||||
val mime = resolvedMime?.takeIf { it.isNotBlank() } ?: fallbackMime
|
||||
val length =
|
||||
runCatching {
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
pfd.statSize.takeIf { it >= 0 }
|
||||
}
|
||||
}.getOrNull()
|
||||
val stream =
|
||||
runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
|
||||
?: throw IOException("Could not open content URI: $uriString")
|
||||
return stream.use { inputStream ->
|
||||
consumer(
|
||||
inputStream,
|
||||
StreamInfo(
|
||||
mimeType = mime,
|
||||
extension = extensionForMime(mime),
|
||||
length = length,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> openFileUriStream(
|
||||
uriString: String,
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||
return file.inputStream().use { inputStream ->
|
||||
consumer(
|
||||
inputStream,
|
||||
StreamInfo(
|
||||
mimeType = mime,
|
||||
extension = extensionForMime(mime),
|
||||
length = file.length().takeIf { it >= 0 },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> openLocalFileStream(
|
||||
filePath: String,
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val file = File(filePath)
|
||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: $filePath")
|
||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||
return file.inputStream().use { inputStream ->
|
||||
consumer(
|
||||
inputStream,
|
||||
StreamInfo(
|
||||
mimeType = mime,
|
||||
extension = extensionForMime(mime),
|
||||
length = file.length().takeIf { it >= 0 },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> openHttpStream(
|
||||
url: String,
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = MediaDataSourceProvider.mediaOkHttpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw IOException("Download failed (HTTP ${response.code})")
|
||||
}
|
||||
val body = response.body
|
||||
return response.use { resp ->
|
||||
val headerMime = resp.header("Content-Type")?.substringBefore(";")?.trim()
|
||||
val mime =
|
||||
when {
|
||||
!headerMime.isNullOrBlank() && headerMime != "application/octet-stream" -> headerMime
|
||||
fallbackMime.isNotBlank() && fallbackMime != "application/octet-stream" -> fallbackMime
|
||||
else -> mediaMimeForPath(url)
|
||||
}
|
||||
val length = body.contentLength().takeIf { it >= 0 }
|
||||
body.byteStream().use { inputStream ->
|
||||
consumer(
|
||||
inputStream,
|
||||
StreamInfo(
|
||||
mimeType = mime,
|
||||
extension = extensionForMime(mime),
|
||||
length = length,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,10 @@ import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* File-kind classification for gateway-hosted files, mirroring the desktop app's
|
||||
* File-kind classification for media and gateway-hosted files, mirroring the desktop app's
|
||||
* `MEDIA_BY_EXT` table (`apps/desktop/src/lib/media.ts`). Lets UI code decide
|
||||
* how to present a downloaded file — inline image, audio player, video player,
|
||||
* how to present a file — inline image, audio player, video player,
|
||||
* or a generic file card — without re-deriving the mapping at every call site.
|
||||
*
|
||||
* Extend the table (not the call sites) when new extensions are needed.
|
||||
*/
|
||||
enum class MediaKind {
|
||||
IMAGE,
|
||||
@@ -20,6 +18,8 @@ enum class MediaKind {
|
||||
|
||||
private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
|
||||
mapOf(
|
||||
"3gp" to (MediaKind.VIDEO to "video/3gpp"),
|
||||
"aac" to (MediaKind.AUDIO to "audio/aac"),
|
||||
"avi" to (MediaKind.VIDEO to "video/x-msvideo"),
|
||||
"bmp" to (MediaKind.IMAGE to "image/bmp"),
|
||||
"csv" to (MediaKind.FILE to "text/csv"),
|
||||
@@ -42,50 +42,189 @@ private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
|
||||
"webp" to (MediaKind.IMAGE to "image/webp"),
|
||||
)
|
||||
|
||||
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
|
||||
fun mediaKindForPath(path: String): MediaKind {
|
||||
val ext =
|
||||
path
|
||||
.split('?', limit = 2)[0]
|
||||
.split('.')
|
||||
.lastOrNull()
|
||||
/**
|
||||
* Extracts the file extension from a clean path or URL, ignoring query parameters and fragments.
|
||||
* Never searches queries for extensions to avoid false positives.
|
||||
*/
|
||||
fun extractFileExtension(pathOrUrl: String): String {
|
||||
val clean =
|
||||
pathOrUrl
|
||||
.substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.trim()
|
||||
val lastSegment = clean.substringAfterLast('/').substringAfterLast('\\')
|
||||
val ext = lastSegment.substringAfterLast('.', "")
|
||||
return if (ext == lastSegment) "" else ext.lowercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the file extension for a media source, handling gateway URLs where the path
|
||||
* is encoded inside the `path=` query parameter.
|
||||
*/
|
||||
fun extractExtensionFromMediaSource(source: String): String {
|
||||
val sourcePath = source.substringBefore('?').substringBefore('#')
|
||||
val gatewayPath =
|
||||
if (sourcePath.endsWith("/api/files/download") || sourcePath.endsWith("/api/files/stream")) {
|
||||
gatewayPathFromUrl(source)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
gatewayPath?.let { gwPath ->
|
||||
val ext = extractFileExtension(gwPath)
|
||||
if (ext.isNotBlank()) return ext
|
||||
}
|
||||
return extractFileExtension(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robust classification of a media item by concrete MIME type first, then by file extension
|
||||
* from [name] or [uri]. Concrete MIME types take precedence (e.g. "audio/mp4" is recognized as AUDIO).
|
||||
*/
|
||||
fun classifyMedia(
|
||||
mimeType: String? = null,
|
||||
name: String? = null,
|
||||
uri: String? = null,
|
||||
): MediaKind {
|
||||
val cleanMime =
|
||||
mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
.orEmpty()
|
||||
return MEDIA_BY_EXT[ext]?.first ?: MediaKind.FILE
|
||||
if (cleanMime.isNotBlank() && cleanMime != "application/octet-stream" && cleanMime != "*/*") {
|
||||
if (cleanMime.startsWith("audio/") || cleanMime == "application/ogg" || cleanMime == "application/x-flac") {
|
||||
return MediaKind.AUDIO
|
||||
}
|
||||
if (cleanMime.startsWith("video/")) {
|
||||
return MediaKind.VIDEO
|
||||
}
|
||||
if (cleanMime.startsWith("image/")) {
|
||||
return MediaKind.IMAGE
|
||||
}
|
||||
return MediaKind.FILE
|
||||
}
|
||||
if (uri?.startsWith("data:", ignoreCase = true) == true) {
|
||||
return classifyMedia(mimeType = uri.substringAfter(':').substringBefore(';').substringBefore(','))
|
||||
}
|
||||
|
||||
val extFromName = name?.let { extractFileExtension(it) }.orEmpty()
|
||||
if (extFromName.isNotBlank()) {
|
||||
MEDIA_BY_EXT[extFromName]?.first?.let { return it }
|
||||
}
|
||||
|
||||
val extFromUri = uri?.let { extractExtensionFromMediaSource(it) }.orEmpty()
|
||||
if (extFromUri.isNotBlank()) {
|
||||
MEDIA_BY_EXT[extFromUri]?.first?.let { return it }
|
||||
}
|
||||
|
||||
return MediaKind.FILE
|
||||
}
|
||||
|
||||
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
|
||||
fun mediaKindForPath(path: String): MediaKind = classifyMedia(uri = path)
|
||||
|
||||
/** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */
|
||||
fun mediaMimeForPath(path: String): String {
|
||||
val ext =
|
||||
path
|
||||
.split('?', limit = 2)[0]
|
||||
.split('.')
|
||||
.lastOrNull()
|
||||
?.lowercase()
|
||||
.orEmpty()
|
||||
val ext = extractExtensionFromMediaSource(path)
|
||||
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream"
|
||||
}
|
||||
|
||||
/** Decoded gateway file path carried in a `/api/files/download` URL. */
|
||||
/**
|
||||
* Normalizes a MIME type string for Android framework boundaries (MediaStore, Intent, SAF),
|
||||
* stripping parameters such as codecs (e.g. `audio/ogg; codecs=opus` -> `audio/ogg`).
|
||||
*/
|
||||
fun normalizeFrameworkMime(mime: String?): String =
|
||||
mime
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "application/octet-stream"
|
||||
|
||||
/** Returns the canonical file extension for a given MIME type. */
|
||||
fun extensionForMime(mime: String): String {
|
||||
val clean = mime.substringBefore(';').trim().lowercase()
|
||||
for ((ext, pair) in MEDIA_BY_EXT) {
|
||||
if (pair.second
|
||||
.substringBefore(';')
|
||||
.trim()
|
||||
.lowercase() == clean
|
||||
) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
return when (clean) {
|
||||
"image/jpeg", "image/jpg" -> {
|
||||
"jpg"
|
||||
}
|
||||
|
||||
"video/mp4" -> {
|
||||
"mp4"
|
||||
}
|
||||
|
||||
"video/x-matroska" -> {
|
||||
"mkv"
|
||||
}
|
||||
|
||||
"video/quicktime" -> {
|
||||
"mov"
|
||||
}
|
||||
|
||||
"audio/mpeg", "audio/mp3" -> {
|
||||
"mp3"
|
||||
}
|
||||
|
||||
"audio/mp4", "audio/x-m4a" -> {
|
||||
"m4a"
|
||||
}
|
||||
|
||||
"audio/wav", "audio/x-wav", "audio/wave" -> {
|
||||
"wav"
|
||||
}
|
||||
|
||||
"audio/flac", "application/x-flac" -> {
|
||||
"flac"
|
||||
}
|
||||
|
||||
"audio/ogg", "application/ogg" -> {
|
||||
"ogg"
|
||||
}
|
||||
|
||||
"audio/aac" -> {
|
||||
"aac"
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
clean.startsWith("video/") -> "mp4"
|
||||
clean.startsWith("audio/") -> "mp3"
|
||||
clean.startsWith("image/") -> "img"
|
||||
else -> "bin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decoded gateway file path carried in a `/api/files/download` or `/api/files/stream` URL. */
|
||||
fun gatewayPathFromUrl(url: String): String? =
|
||||
Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let {
|
||||
runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it)
|
||||
}
|
||||
|
||||
/** Trailing filename from a path or URL.
|
||||
*
|
||||
/**
|
||||
* Trailing filename from a path or URL.
|
||||
* Handles both a bare gateway path (`/tmp/report.pdf`) and a full
|
||||
* `/api/files/download?path=<enc>` URL, where the real filename lives in the
|
||||
* percent-encoded `path=` query parameter. */
|
||||
* percent-encoded `path=` query parameter.
|
||||
*/
|
||||
fun mediaNameFromPath(path: String): String {
|
||||
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
|
||||
val cleanPath = path.split('?', limit = 2)[0]
|
||||
val cleanPath = path.substringBefore('#').substringBefore('?')
|
||||
return runCatching {
|
||||
java.net
|
||||
.URI(cleanPath)
|
||||
.path
|
||||
.split('/')
|
||||
.lastOrNull { it.isNotBlank() }
|
||||
?.split('/')
|
||||
?.lastOrNull { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() }
|
||||
?: path
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
@@ -43,6 +45,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -57,6 +60,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@@ -65,13 +69,21 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBg
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||
import com.m57.hermescontrol.ui.chat.components.highlightSyntax
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Composes the collapsed summary lines for a tool card.
|
||||
@@ -237,34 +249,7 @@ internal fun ToolBubble(
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
if (showRawJson) {
|
||||
// Raw JSON view — selectable + copy button
|
||||
Box {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
CopyButton(
|
||||
visible = showCopyButton,
|
||||
textToCopy = message.content,
|
||||
onCopy = { showCopyButton = false },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-8).dp),
|
||||
)
|
||||
}
|
||||
ToolRawJsonView(rawContent = message.content)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -310,34 +295,8 @@ internal fun ToolBubble(
|
||||
.clickable(role = Role.Button) { showRawJson = true },
|
||||
)
|
||||
} else {
|
||||
// Unparseable content — show raw JSON, selectable
|
||||
Box {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
CopyButton(
|
||||
visible = showCopyButton,
|
||||
textToCopy = message.content,
|
||||
onCopy = { showCopyButton = false },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-8).dp),
|
||||
)
|
||||
}
|
||||
// Unparseable content — show raw JSON
|
||||
ToolRawJsonView(rawContent = message.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,6 +609,123 @@ internal fun SecurityRiskChip(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolRawJsonView(
|
||||
rawContent: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var formatJson by remember { mutableStateOf(true) }
|
||||
val formattedContent = remember(rawContent) { ToolJson.prettyPrintJson(rawContent) }
|
||||
val isFormatDifferent = formattedContent != rawContent
|
||||
val displayText = if (formatJson && isFormatDifferent) formattedContent else rawContent
|
||||
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(displayText) { AnnotatedString(displayText) },
|
||||
key1 = displayText,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(displayText)
|
||||
}
|
||||
}
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("tool_raw_json"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CodeTerminalBg,
|
||||
border = BorderStroke(1.dp, CodeTerminalBorder),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "JSON",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CodeTerminalMuted,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (isFormatDifferent) {
|
||||
Text(
|
||||
text =
|
||||
if (formatJson) {
|
||||
stringResource(R.string.chat_tool_compact_json)
|
||||
} else {
|
||||
stringResource(R.string.chat_tool_format_json)
|
||||
},
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("tool_json_format_toggle")
|
||||
.clickable(role = Role.Button) { formatJson = !formatJson }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText)))
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription =
|
||||
if (copied) {
|
||||
stringResource(R.string.content_desc_copied)
|
||||
} else {
|
||||
stringResource(R.string.content_desc_copy)
|
||||
},
|
||||
tint = CodeTerminalMuted,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = highlighted,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = CodeTerminalText,
|
||||
softWrap = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
visible: Boolean,
|
||||
|
||||
@@ -80,6 +80,8 @@ fun ChatInputBar(
|
||||
isAgentTyping: Boolean,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
isSessionReady: Boolean,
|
||||
sessionPreparationFailed: Boolean = false,
|
||||
slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||
pendingAttachments: List<Attachment> = emptyList(),
|
||||
onCameraTap: () -> Unit = {},
|
||||
@@ -98,12 +100,13 @@ fun ChatInputBar(
|
||||
fastSupported: Boolean = false,
|
||||
isFastModeChanging: Boolean = false,
|
||||
onToggleFastMode: () -> Unit = {},
|
||||
showModelProvider: Boolean = false,
|
||||
) {
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
|
||||
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
@@ -203,6 +206,22 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnected && !isSessionReady) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (sessionPreparationFailed) {
|
||||
R.string.chat_session_not_ready
|
||||
} else {
|
||||
R.string.chat_session_preparing
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.placeholder,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── TOP ROW: Borderless input field ──
|
||||
Row(
|
||||
modifier =
|
||||
@@ -296,6 +315,7 @@ fun ChatInputBar(
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
onSend = onSend,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
@@ -308,6 +328,7 @@ fun ChatInputBar(
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
showModelProvider = showModelProvider,
|
||||
)
|
||||
|
||||
AttachmentTray(
|
||||
|
||||
@@ -10,18 +10,24 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.notification.correlationScopeId
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.SecretPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.SudoPromptUi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ChatLifecycleEffects(
|
||||
@@ -39,6 +45,7 @@ fun ChatLifecycleEffects(
|
||||
scrollController: ChatScrollController,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
viewModel: ChatViewModel,
|
||||
isOverlayActive: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -87,6 +94,32 @@ fun ChatLifecycleEffects(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val messageMap = remember(messages) { messages.associateBy { it.id } }
|
||||
|
||||
// Auto-dismiss reply notifications when their message is displayed in the viewport
|
||||
LaunchedEffect(lifecycleOwner, currentSessionId, messageMap, listState, isOverlayActive) {
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
snapshotFlow<List<ChatMessage>> {
|
||||
if (currentSessionId.isNullOrBlank() || isOverlayActive) {
|
||||
emptyList()
|
||||
} else {
|
||||
ChatReadObserver.findVisibleAssistantMessages(listState.layoutInfo, messageMap)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.collect { visibleAssistantMsgs ->
|
||||
val scopeId = correlationScopeId()
|
||||
for (msg in visibleAssistantMsgs) {
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = scopeId,
|
||||
sessionId = currentSessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
val requestNotificationPermission =
|
||||
rememberLauncherForActivityResult(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
|
||||
object ChatReadObserver {
|
||||
/**
|
||||
* Inspects the LazyColumn's visible items to determine which completed assistant
|
||||
* messages currently have any portion visible within the viewport.
|
||||
*
|
||||
* In [com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList], assistant prose
|
||||
* bubbles are keyed with `"prose-${message.id}"`. An item is visible when its top
|
||||
* edge is before the viewport end offset AND its bottom edge (offset + size) is past
|
||||
* the viewport start offset.
|
||||
*/
|
||||
fun findVisibleAssistantMessages(
|
||||
layoutInfo: LazyListLayoutInfo,
|
||||
messageMap: Map<String, ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val visible = layoutInfo.visibleItemsInfo
|
||||
if (visible.isEmpty() || messageMap.isEmpty()) return emptyList()
|
||||
|
||||
val start = layoutInfo.viewportStartOffset
|
||||
val end = layoutInfo.viewportEndOffset
|
||||
|
||||
return visible
|
||||
.filter { it.offset + it.size > start && it.offset < end }
|
||||
.mapNotNull { item ->
|
||||
val key = item.key as? String ?: return@mapNotNull null
|
||||
if (key.startsWith("prose-")) {
|
||||
val msgId = key.removePrefix("prose-")
|
||||
val msg = messageMap[msgId]
|
||||
if (msg != null && msg.role == MessageRole.ASSISTANT && !msg.isStreaming) {
|
||||
msg
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findVisibleAssistantMessages(
|
||||
layoutInfo: LazyListLayoutInfo,
|
||||
messages: List<ChatMessage>,
|
||||
): List<ChatMessage> = findVisibleAssistantMessages(layoutInfo, messages.associateBy { it.id })
|
||||
}
|
||||
@@ -95,6 +95,7 @@ fun ComposerToolbar(
|
||||
onMicTap: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
canSend: Boolean = false,
|
||||
showSend: Boolean = canSend,
|
||||
onSend: () -> Unit = {},
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
@@ -102,6 +103,7 @@ fun ComposerToolbar(
|
||||
fastSupported: Boolean = false,
|
||||
isFastModeChanging: Boolean = false,
|
||||
onToggleFastMode: () -> Unit = {},
|
||||
showModelProvider: Boolean = false,
|
||||
) {
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
val palette = composerPalette()
|
||||
@@ -134,7 +136,11 @@ fun ComposerToolbar(
|
||||
// Model + reasoning pill — wraps its content inside the free space,
|
||||
// pushing the mic/action buttons to the far end
|
||||
val modelScrollState = rememberScrollState()
|
||||
LaunchedEffect(currentSessionModel) {
|
||||
val modelLabel =
|
||||
currentSessionModel?.let { model ->
|
||||
composerModelLabel(model, showProvider = showModelProvider)
|
||||
} ?: "Model"
|
||||
LaunchedEffect(modelLabel) {
|
||||
modelScrollState.scrollTo(0)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||
@@ -158,9 +164,7 @@ fun ComposerToolbar(
|
||||
.testTag("model_chip"),
|
||||
) {
|
||||
Text(
|
||||
// Keeps the provider so same-named models from different providers
|
||||
// stay distinguishable while allowing the full label to be revealed.
|
||||
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
|
||||
text = modelLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = palette.onControl,
|
||||
maxLines = 1,
|
||||
@@ -360,7 +364,7 @@ fun ComposerToolbar(
|
||||
|
||||
// Flat mic / stop button — only while the action button is in send mode
|
||||
AnimatedVisibility(
|
||||
visible = canSend,
|
||||
visible = showSend,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||
) {
|
||||
@@ -382,10 +386,10 @@ fun ComposerToolbar(
|
||||
|
||||
// Action button — send when a send is possible, mic / stop otherwise
|
||||
FilledIconButton(
|
||||
onClick = if (canSend) onSend else onMicTap,
|
||||
enabled = canSend || isConnected,
|
||||
onClick = if (showSend) onSend else onMicTap,
|
||||
enabled = if (showSend) canSend else isConnected,
|
||||
colors =
|
||||
if (!canSend && isListening) {
|
||||
if (!showSend && isListening) {
|
||||
listeningIconButtonColors()
|
||||
} else {
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
@@ -398,7 +402,7 @@ fun ComposerToolbar(
|
||||
.size(ControlSize)
|
||||
.testTag(
|
||||
when {
|
||||
canSend -> "send_button"
|
||||
showSend -> "send_button"
|
||||
isListening -> "mic_stop_button"
|
||||
else -> "mic_button"
|
||||
},
|
||||
@@ -407,7 +411,7 @@ fun ComposerToolbar(
|
||||
Crossfade(
|
||||
targetState =
|
||||
when {
|
||||
canSend -> ActionGlyph.SEND
|
||||
showSend -> ActionGlyph.SEND
|
||||
isListening -> ActionGlyph.STOP
|
||||
else -> ActionGlyph.VOICE
|
||||
},
|
||||
@@ -453,19 +457,39 @@ private fun listeningIconButtonColors(): IconButtonColors =
|
||||
)
|
||||
|
||||
/**
|
||||
* Model label for the composer pill: the session's "provider/model" id with
|
||||
* Model label for the composer pill:
|
||||
* When [showProvider] is true, shows the session's "provider/model" id with
|
||||
* only the "custom:" marker removed from the provider ("custom:acme/glm-5.3"
|
||||
* → "acme/glm-5.3"). The provider itself is kept so the same model served by
|
||||
* → "acme/glm-5.3"). The provider is kept so the same model served by
|
||||
* different providers never renders identically.
|
||||
* When [showProvider] is false (default), shows only the model name ("openai/gpt-5"
|
||||
* → "gpt-5", "custom:acme/glm-5.3" → "glm-5.3").
|
||||
*/
|
||||
internal fun composerModelLabel(sessionModel: String): String {
|
||||
internal fun composerModelLabel(
|
||||
sessionModel: String,
|
||||
showProvider: Boolean = false,
|
||||
): String {
|
||||
val slash = sessionModel.indexOf('/')
|
||||
if (slash <= 0) return sessionModel
|
||||
val provider = sessionModel.substring(0, slash)
|
||||
if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) {
|
||||
return sessionModel
|
||||
|
||||
val rawProvider = sessionModel.substring(0, slash)
|
||||
val model = sessionModel.substring(slash + 1)
|
||||
if (model.isBlank()) return sessionModel
|
||||
|
||||
if (rawProvider == CUSTOM_PROVIDER_PREFIX) return sessionModel
|
||||
|
||||
if (!showProvider) {
|
||||
val leaf = sessionModel.substringAfterLast('/')
|
||||
return if (leaf.isNotBlank()) leaf else sessionModel
|
||||
}
|
||||
return provider.removePrefix(CUSTOM_PROVIDER_PREFIX) + sessionModel.substring(slash)
|
||||
|
||||
val provider =
|
||||
if (rawProvider.startsWith(CUSTOM_PROVIDER_PREFIX) && rawProvider.length > CUSTOM_PROVIDER_PREFIX.length) {
|
||||
rawProvider.removePrefix(CUSTOM_PROVIDER_PREFIX)
|
||||
} else {
|
||||
rawProvider
|
||||
}
|
||||
return "$provider/$model"
|
||||
}
|
||||
|
||||
private const val CUSTOM_PROVIDER_PREFIX = "custom:"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.MediaKind
|
||||
import com.m57.hermescontrol.ui.chat.classifyMedia
|
||||
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
|
||||
|
||||
/**
|
||||
* Shared inline media preview card for audio and video in chat bubbles and markdown blocks.
|
||||
* Renders a compact file-style card for audio and a 16:9 preview for video.
|
||||
*/
|
||||
@Composable
|
||||
fun InlineMediaPlayer(
|
||||
uri: String,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
mimeType: String? = null,
|
||||
onFullScreenClick: () -> Unit = {},
|
||||
) {
|
||||
val mediaKind =
|
||||
remember(uri, mimeType, title) {
|
||||
classifyMedia(mimeType = mimeType, name = title, uri = uri)
|
||||
}
|
||||
|
||||
val displayName =
|
||||
remember(uri, title) {
|
||||
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(uri)
|
||||
}
|
||||
|
||||
if (mediaKind == MediaKind.AUDIO) {
|
||||
InlineAudioPreview(
|
||||
name = displayName,
|
||||
modifier = modifier,
|
||||
onClick = onFullScreenClick,
|
||||
)
|
||||
} else {
|
||||
InlineVideoPreview(
|
||||
modifier = modifier,
|
||||
onClick = onFullScreenClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineAudioPreview(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.testTag("inline_audio_player"),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.testTag("audio_play_overlay"),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Audiotrack,
|
||||
contentDescription = stringResource(R.string.media_player_audio),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.media_player_audio).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = stringResource(R.string.media_player_play_audio),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineVideoPreview(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.clickable { onClick() }
|
||||
.testTag("inline_video_player"),
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(56.dp)
|
||||
.testTag("video_play_overlay"),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = stringResource(R.string.media_player_play_video),
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = stringResource(R.string.media_player_video),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
Text(
|
||||
text = " " + stringResource(R.string.media_player_video).uppercase(),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
fontSize = 10.sp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
/**
|
||||
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
|
||||
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
|
||||
* Backwards-compatible wrapper delegating to [InlineMediaPlayer].
|
||||
*/
|
||||
@Composable
|
||||
fun InlineVideoPlayer(
|
||||
@@ -36,65 +12,11 @@ fun InlineVideoPlayer(
|
||||
modifier: Modifier = Modifier,
|
||||
onFullScreenClick: () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.clickable { onFullScreenClick() }
|
||||
.testTag("inline_video_player"),
|
||||
) {
|
||||
// Play icon badge in center
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(56.dp)
|
||||
.testTag("video_play_overlay"),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = stringResource(R.string.video_play),
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// VIDEO label badge in bottom-right corner
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Video",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
Text(
|
||||
text = " VIDEO",
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
fontSize = 10.sp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
InlineMediaPlayer(
|
||||
uri = videoUri,
|
||||
modifier = modifier,
|
||||
onFullScreenClick = onFullScreenClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Formats milliseconds into `M:SS` or `H:MM:SS` */
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Replay
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.MediaExportHelper
|
||||
import com.m57.hermescontrol.ui.chat.MediaKind
|
||||
import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
|
||||
import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
|
||||
import com.m57.hermescontrol.ui.chat.classifyMedia
|
||||
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
|
||||
import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
|
||||
import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun MediaViewerDialog(
|
||||
mediaUri: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
title: String? = null,
|
||||
mimeType: String? = null,
|
||||
) {
|
||||
key(mediaUri, mimeType) {
|
||||
MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
private fun MediaViewerContent(
|
||||
mediaUri: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
title: String?,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mediaKind =
|
||||
remember(mediaUri, mimeType, title) {
|
||||
classifyMedia(mimeType = mimeType, name = title, uri = mediaUri)
|
||||
}
|
||||
val isAudio = mediaKind == MediaKind.AUDIO
|
||||
val displayName =
|
||||
remember(mediaUri, title) {
|
||||
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri)
|
||||
}
|
||||
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var isBuffering by remember { mutableStateOf(true) }
|
||||
var isEnded by remember { mutableStateOf(false) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
|
||||
var currentPositionMs by remember { mutableLongStateOf(0L) }
|
||||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragPositionMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val saveFailed = stringResource(R.string.media_player_save_failed)
|
||||
val shareFailed = stringResource(R.string.media_player_share_failed)
|
||||
val shareTitle = stringResource(R.string.media_player_share_title)
|
||||
|
||||
val player =
|
||||
remember(context, mediaUri) {
|
||||
val dataSourceFactory = MediaDataSourceProvider.createDataSourceFactory(context)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
|
||||
val audioAttributes =
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_MUSIC else C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setRenderersFactory(
|
||||
// Emulator guard: the goldfish "hardware" codecs crash on queueInputBuffer
|
||||
// ("Decoder failed: c2.goldfish.h264.decoder"); deprioritize them so the
|
||||
// reliable software codecs are selected instead. Real devices never ship
|
||||
// goldfish codecs, so this has no effect on phones.
|
||||
androidx.media3.exoplayer
|
||||
.DefaultRenderersFactory(context)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
|
||||
androidx.media3.exoplayer.mediacodec.MediaCodecSelector.DEFAULT
|
||||
.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||
.sortedBy { it.name.contains("goldfish", ignoreCase = true) }
|
||||
},
|
||||
).setAudioAttributes(audioAttributes, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.build()
|
||||
.apply {
|
||||
setMediaItem(MediaItem.fromUri(Uri.parse(mediaUri)))
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
isBuffering = true
|
||||
}
|
||||
|
||||
Player.STATE_READY -> {
|
||||
isBuffering = false
|
||||
hasError = false
|
||||
durationMs = player.duration.coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
Player.STATE_ENDED -> {
|
||||
isBuffering = false
|
||||
isPlaying = false
|
||||
isEnded = true
|
||||
currentPositionMs = durationMs
|
||||
}
|
||||
|
||||
Player.STATE_IDLE -> {
|
||||
isBuffering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
|
||||
isPlaying = isPlayingNow
|
||||
if (isPlayingNow) {
|
||||
isEnded = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
isBuffering = false
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
player.prepare()
|
||||
player.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, player) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player, isPlaying, isDragging) {
|
||||
while (isPlaying && !isDragging) {
|
||||
currentPositionMs = player.currentPosition.coerceAtLeast(0L)
|
||||
if (durationMs <= 0L && player.duration > 0L) {
|
||||
durationMs = player.duration
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
|
||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
|
||||
prepareForBackground = { NotificationHelper.start(context) },
|
||||
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
|
||||
launch = launch,
|
||||
)
|
||||
}
|
||||
|
||||
val saveDocumentLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
try {
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
|
||||
if (destination != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val msg =
|
||||
MediaExportHelper.saveMediaToUri(
|
||||
context = context,
|
||||
sourceUri = mediaUri,
|
||||
targetUri = destination,
|
||||
fallbackMime =
|
||||
mimeType?.takeUnless { it == "application/octet-stream" }
|
||||
?: com.m57.hermescontrol.ui.chat
|
||||
.mediaMimeForPath(displayName),
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
isBusy = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isBusy = false
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
isBusy = false
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
}
|
||||
|
||||
val onSave: () -> Unit = {
|
||||
if (!isBusy) {
|
||||
isBusy = true
|
||||
when (mediaSaveStrategy()) {
|
||||
MediaSaveStrategy.MEDIA_STORE -> {
|
||||
scope.launch {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val msg =
|
||||
MediaExportHelper.saveMediaToDownloads(
|
||||
context = context,
|
||||
uri = mediaUri,
|
||||
fallbackMime =
|
||||
mimeType?.takeUnless { it == "application/octet-stream" }
|
||||
?: com.m57.hermescontrol.ui.chat
|
||||
.mediaMimeForPath(displayName),
|
||||
displayName = displayName,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
isBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaSaveStrategy.CREATE_DOCUMENT -> {
|
||||
val suggestedName =
|
||||
MediaExportHelper.resolveDisplayName(
|
||||
displayName = displayName,
|
||||
mimeType = mimeType,
|
||||
uri = mediaUri,
|
||||
)
|
||||
val rawMime =
|
||||
mimeType?.takeUnless { it == "application/octet-stream" }
|
||||
?: com.m57.hermescontrol.ui.chat
|
||||
.mediaMimeForPath(displayName)
|
||||
val normalizedMime = normalizeFrameworkMime(rawMime)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = normalizedMime
|
||||
putExtra(Intent.EXTRA_TITLE, suggestedName)
|
||||
}
|
||||
try {
|
||||
launchExternalActivity {
|
||||
saveDocumentLauncher.launch(intent)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
isBusy = false
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onShare: () -> Unit = {
|
||||
if (!isBusy) {
|
||||
isBusy = true
|
||||
scope.launch {
|
||||
try {
|
||||
val intent =
|
||||
MediaExportHelper.shareMedia(
|
||||
context = context,
|
||||
uri = mediaUri,
|
||||
fallbackMime =
|
||||
mimeType?.takeUnless { it == "application/octet-stream" }
|
||||
?: com.m57.hermescontrol.ui.chat
|
||||
.mediaMimeForPath(displayName),
|
||||
displayName = displayName,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (intent != null) {
|
||||
val chooser = Intent.createChooser(intent, shareTitle)
|
||||
try {
|
||||
launchExternalActivity {
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
isBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.testTag("media_viewer_dialog"),
|
||||
) {
|
||||
if (!isAudio) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
this.player = player
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Keep the background gesture out of the controls' ancestor chain.
|
||||
Box(
|
||||
Modifier.fillMaxSize().clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (isPlaying && !isDragging) showControls = !showControls
|
||||
},
|
||||
)
|
||||
} else if (!isBuffering && !hasError) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(bottom = 180.dp, start = 24.dp, end = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(100.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Audiotrack,
|
||||
contentDescription = stringResource(R.string.media_player_audio),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top Bar Controls
|
||||
AnimatedVisibility(
|
||||
visible = showControls || isAudio || !isPlaying || isDragging,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.testTag("media_close_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.media_player_close),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onSave,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.testTag("media_save_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.media_player_save),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onShare,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.testTag("media_share_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Share,
|
||||
contentDescription = stringResource(R.string.media_player_share),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center play/pause/loading/error
|
||||
if (isBuffering) {
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(48.dp)
|
||||
.testTag("media_buffering_indicator"),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else if (hasError) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.media_player_load_failed, ""),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = statusColors.error,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
hasError = false
|
||||
isBuffering = true
|
||||
player.prepare()
|
||||
player.play()
|
||||
},
|
||||
modifier = Modifier.size(56.dp).testTag("media_retry_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.media_player_retry),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnimatedVisibility(
|
||||
visible = showControls || isAudio || !isPlaying || isDragging,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.testTag("media_play_pause_button")
|
||||
.clickable {
|
||||
if (isEnded) {
|
||||
player.seekTo(0)
|
||||
player.play()
|
||||
isEnded = false
|
||||
} else if (isPlaying) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val icon =
|
||||
when {
|
||||
isEnded -> Icons.Filled.Replay
|
||||
isPlaying -> Icons.Filled.Pause
|
||||
else -> Icons.Filled.PlayArrow
|
||||
}
|
||||
val desc =
|
||||
when {
|
||||
isEnded -> stringResource(R.string.media_player_replay)
|
||||
isPlaying -> stringResource(R.string.media_player_pause)
|
||||
else -> stringResource(R.string.media_player_play)
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = desc,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Bar Controls
|
||||
AnimatedVisibility(
|
||||
visible = showControls || isAudio || !isPlaying || isDragging,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
val displayPosition = if (isDragging) dragPositionMs else currentPositionMs
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = formatMediaDuration(displayPosition),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.width(48.dp),
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = displayPosition.coerceIn(0L, durationMs.coerceAtLeast(1L)).toFloat(),
|
||||
onValueChange = {
|
||||
isDragging = true
|
||||
dragPositionMs = it.toLong()
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
player.seekTo(dragPositionMs)
|
||||
currentPositionMs = dragPositionMs
|
||||
isEnded = false
|
||||
isDragging = false
|
||||
},
|
||||
enabled = durationMs > 0 && !hasError && player.isCurrentMediaItemSeekable,
|
||||
valueRange = 0f..durationMs.coerceAtLeast(1L).toFloat(),
|
||||
modifier = Modifier.weight(1f).testTag("media_seek_slider"),
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatMediaDuration(durationMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.width(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -57,6 +55,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -91,7 +90,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// ── ReasoningCard ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -317,7 +318,15 @@ fun CodeBlockCard(
|
||||
}
|
||||
}
|
||||
// Code content with syntax highlighting
|
||||
val highlighted = remember(code) { highlightSyntax(code) }
|
||||
val highlighted by produceState(
|
||||
initialValue = remember(code) { AnnotatedString(code) },
|
||||
key1 = code,
|
||||
) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
highlightSyntax(code)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = highlighted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@@ -338,6 +347,7 @@ private val HIGHLIGHT_TOKENS =
|
||||
listOf(
|
||||
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
|
||||
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
|
||||
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
|
||||
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
|
||||
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
|
||||
@@ -354,7 +364,7 @@ private val HIGHLIGHT_TOKENS =
|
||||
),
|
||||
CodeKeyword,
|
||||
),
|
||||
TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation),
|
||||
TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,382 +1,17 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.ImageBytesResolver
|
||||
import com.m57.hermescontrol.ui.chat.MediaImageStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Fullscreen Video Viewer modal dialog with Save & Share (issue #722).
|
||||
* Backwards-compatible wrapper delegating to [MediaViewerDialog].
|
||||
*/
|
||||
@Composable
|
||||
fun VideoViewerDialog(
|
||||
videoUri: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isPlaying by remember { mutableStateOf(true) }
|
||||
var currentPositionMs by remember { mutableLongStateOf(0L) }
|
||||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
var videoViewRef by remember { mutableStateOf<VideoView?>(null) }
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
val savedMsg = stringResource(R.string.image_viewer_saved)
|
||||
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
|
||||
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
|
||||
val shareTitle = stringResource(R.string.image_viewer_share_title)
|
||||
|
||||
val onSave: () -> Unit = {
|
||||
if (!isBusy) {
|
||||
isBusy = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
|
||||
val result =
|
||||
when (resolved) {
|
||||
is ImageBytesResolver.Result.Bytes -> {
|
||||
val uri =
|
||||
MediaImageStore.saveToDownloads(
|
||||
context,
|
||||
resolved.bytes,
|
||||
"hermes-video.${resolved.extension}",
|
||||
resolved.mimeType,
|
||||
)
|
||||
if (uri != null) savedMsg else saveFailedMsg
|
||||
}
|
||||
|
||||
is ImageBytesResolver.Result.Error -> {
|
||||
String.format(loadFailedFmt, resolved.message)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
isBusy = false
|
||||
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onShare: () -> Unit = {
|
||||
if (!isBusy) {
|
||||
isBusy = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
|
||||
val intent =
|
||||
when (resolved) {
|
||||
is ImageBytesResolver.Result.Bytes -> {
|
||||
MediaImageStore.buildShareIntent(
|
||||
context,
|
||||
resolved.bytes,
|
||||
"hermes-video.${resolved.extension}",
|
||||
resolved.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
is ImageBytesResolver.Result.Error -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
isBusy = false
|
||||
if (intent != null) {
|
||||
context.startActivity(android.content.Intent.createChooser(intent, shareTitle))
|
||||
} else {
|
||||
Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
videoViewRef?.let { view ->
|
||||
if (view.isPlaying) {
|
||||
currentPositionMs = view.currentPosition.toLong()
|
||||
if (durationMs <= 0 && view.duration > 0) {
|
||||
durationMs = view.duration.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(videoUri) {
|
||||
onDispose {
|
||||
videoViewRef?.stopPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
MediaViewerDialog(
|
||||
mediaUri = videoUri,
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim)
|
||||
.clickable { showControls = !showControls }
|
||||
.testTag("video_viewer_dialog"),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
VideoView(ctx).apply {
|
||||
val uri = Uri.parse(videoUri)
|
||||
val headers = mutableMapOf<String, String>()
|
||||
val cookieVal =
|
||||
com.m57.hermescontrol.data.remote.CookieManager
|
||||
.getSessionCookie()
|
||||
if (!cookieVal.isNullOrBlank()) {
|
||||
headers["Cookie"] = "hermes_session_at=$cookieVal"
|
||||
}
|
||||
val token =
|
||||
com.m57.hermescontrol.data.local.AuthManager
|
||||
.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
headers["Authorization"] = "Bearer $token"
|
||||
}
|
||||
setVideoURI(uri, headers)
|
||||
setOnPreparedListener { mp ->
|
||||
durationMs = mp.duration.toLong()
|
||||
start()
|
||||
isPlaying = true
|
||||
}
|
||||
setOnCompletionListener {
|
||||
isPlaying = false
|
||||
currentPositionMs = durationMs
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
videoViewRef = view
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// Center overlay play button when paused
|
||||
if (!isPlaying) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(64.dp)
|
||||
.clickable {
|
||||
videoViewRef?.let { view ->
|
||||
view.start()
|
||||
isPlaying = true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = stringResource(R.string.video_play),
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top Bar with Back/Close, Save, Share
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.70f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.testTag("close_video_dialog_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onSave, enabled = !isBusy) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = "Save",
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = !isBusy) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Share,
|
||||
contentDescription = "Share",
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating Bottom Controls Bar
|
||||
AnimatedVisibility(
|
||||
visible = showControls || !isPlaying,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.80f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
videoViewRef?.let { view ->
|
||||
if (isPlaying) {
|
||||
view.pause()
|
||||
isPlaying = false
|
||||
} else {
|
||||
view.start()
|
||||
isPlaying = true
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = formatMediaDuration(currentPositionMs),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = if (durationMs > 0) currentPositionMs.toFloat() / durationMs.toFloat() else 0f,
|
||||
onValueChange = { fraction ->
|
||||
val targetMs = (fraction * durationMs).toLong()
|
||||
currentPositionMs = targetMs
|
||||
videoViewRef?.seekTo(targetMs.toInt())
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.3f),
|
||||
),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatMediaDuration(durationMs),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -314,4 +314,27 @@ internal object ToolJson {
|
||||
|
||||
return formatDurationSeconds(seconds)
|
||||
}
|
||||
|
||||
private val PRETTY_JSON =
|
||||
Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = " "
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints a raw JSON string with 2-space indentation.
|
||||
* Returns the original string if [raw] is not valid JSON.
|
||||
*/
|
||||
fun prettyPrintJson(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return raw
|
||||
}
|
||||
return try {
|
||||
val element = Json.parseToJsonElement(trimmed)
|
||||
PRETTY_JSON.encodeToString(JsonElement.serializer(), element)
|
||||
} catch (_: Exception) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ internal interface ToolRenderer {
|
||||
/** Expanded body text. */
|
||||
fun detail(call: ToolCall): String? = null
|
||||
|
||||
/** Expanded body text while the call is still running. */
|
||||
fun pendingDetail(call: ToolCall): String? = null
|
||||
|
||||
/** Structured extras (streams, diffs, search hits) for special row layouts. */
|
||||
fun extras(
|
||||
call: ToolCall,
|
||||
|
||||
@@ -64,7 +64,13 @@ object ToolViewBuilder {
|
||||
|
||||
val detailBody =
|
||||
ToolJson.stripDividerLines(
|
||||
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult),
|
||||
if (running) {
|
||||
renderer.pendingDetail(call)
|
||||
?: renderer.detail(call)
|
||||
?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
} else {
|
||||
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
},
|
||||
)
|
||||
val detail =
|
||||
if (error.isNotEmpty()) {
|
||||
|
||||
@@ -79,7 +79,7 @@ internal object FileEditSupport {
|
||||
var added = 0
|
||||
var removed = 0
|
||||
|
||||
for (line in diff.split("\n")) {
|
||||
for (line in diff.lineSequence()) {
|
||||
when {
|
||||
line.startsWith("+") && !line.startsWith("+++") -> added += 1
|
||||
line.startsWith("-") && !line.startsWith("---") -> removed += 1
|
||||
|
||||
@@ -105,14 +105,12 @@ internal object SkillManageRenderer : ToolRenderer {
|
||||
|
||||
val msg = ToolJson.firstString(call.result, listOf("message"))
|
||||
val failed = ToolJson.boolFalse(call.result?.get("success"))
|
||||
val succeeded = ToolJson.boolTrue(call.result?.get("success"))
|
||||
|
||||
// Pinned quirk from the original engine: the branch order is inverted
|
||||
// (a non-false `success` renders the failure line). Preserved verbatim
|
||||
// by the characterization suite; fix upstream deliberately if desired.
|
||||
return when {
|
||||
!failed -> "❌ Skill operation failed"
|
||||
msg.isNotEmpty() -> "✅ $msg"
|
||||
else -> "✅ Skill operation done"
|
||||
failed -> "❌ ${msg.ifEmpty { "Skill operation failed" }}"
|
||||
succeeded -> "✅ ${msg.ifEmpty { "Skill operation done" }}"
|
||||
else -> "❌ ${msg.ifEmpty { "Skill operation failed" }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,11 @@ internal object ProcessRenderer : ToolRenderer {
|
||||
return if (procId.isNotEmpty()) "$action: $procId" else action
|
||||
}
|
||||
|
||||
override fun pendingDetail(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
return "⏳ ${action.ifEmpty { "Process operation" }} in progress"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
@@ -248,7 +253,7 @@ internal object ProcessRenderer : ToolRenderer {
|
||||
}.joinToString("\n\n")
|
||||
}
|
||||
|
||||
val output = ToolJson.firstString(result, listOf("output"))
|
||||
val output = ToolJson.firstString(result, listOf("output", "output_preview"))
|
||||
if (output.isNotEmpty()) {
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output"
|
||||
|
||||
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -34,6 +38,69 @@ inline fun <T> ViewModel.safeLaunchLoad(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a read-only API call with Stale-While-Revalidate caching.
|
||||
* If cached data exists in [cache] for [cacheKey] and [forceRefresh] is false, [onCacheHit] is invoked immediately
|
||||
* on the caller thread, avoiding full-screen loading spinners on screen revisits.
|
||||
* The network call runs in the background to update the cache and invoke [onSuccess].
|
||||
* If a previous request is active:
|
||||
* - when [forceRefresh] is false, the active job is returned to avoid duplicate in-flight work
|
||||
* - when [forceRefresh] is true, the active job is cancelled and superseded by the new forced refresh
|
||||
* Results are checked against [requestScope] and coroutine cancellation so late-arriving responses
|
||||
* cannot overwrite newer state.
|
||||
*/
|
||||
inline fun <T : Any> ViewModel.safeLaunchSwrLoad(
|
||||
cache: SwrCache<String, T>,
|
||||
cacheKey: String = "default",
|
||||
forceRefresh: Boolean = false,
|
||||
currentJob: Job? = null,
|
||||
requestScope: DataScope? = runCatching { AuthManager.currentDataScope() }.getOrNull(),
|
||||
crossinline scopeIsCurrent: () -> Boolean = {
|
||||
requestScope == null || AuthManager.currentDataScope() == requestScope
|
||||
},
|
||||
crossinline onCacheHit: (T) -> Unit,
|
||||
crossinline apiCall: suspend () -> NetworkResult<T>,
|
||||
crossinline onStart: () -> Unit,
|
||||
crossinline onSuccess: (T) -> Unit,
|
||||
crossinline onError: (String) -> Unit,
|
||||
): Job {
|
||||
if (currentJob?.isActive == true) {
|
||||
if (!forceRefresh) return currentJob
|
||||
currentJob.cancel()
|
||||
}
|
||||
val scopedKey = requestScope?.scopedKey(cacheKey)
|
||||
if (forceRefresh && scopedKey != null) {
|
||||
cache.remove(scopedKey)
|
||||
}
|
||||
val cached = if (!forceRefresh && scopedKey != null) cache.get(scopedKey) else null
|
||||
if (cached != null) {
|
||||
onCacheHit(cached)
|
||||
} else {
|
||||
onStart()
|
||||
}
|
||||
return viewModelScope.launch {
|
||||
val result = apiCall()
|
||||
coroutineContext.ensureActive()
|
||||
if (!scopeIsCurrent()) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (scopedKey != null) {
|
||||
cache.put(scopedKey, result.data)
|
||||
}
|
||||
onSuccess(result.data)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (cached == null || forceRefresh) {
|
||||
onError(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
|
||||
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
|
||||
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
|
||||
.collect { _ ->
|
||||
if (refreshInFlight) return@collect
|
||||
refreshInFlight = true
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
try {
|
||||
// No Dispatchers.IO hop — see safeLaunchLoad.
|
||||
val result = apiCall()
|
||||
coroutineContext.ensureActive()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@collect
|
||||
if (result is NetworkResult.Success) {
|
||||
onSuccess(result.data)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ fun ConfigScreen(
|
||||
title = { Text(stringResource(R.string.config_screen_title)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAll() },
|
||||
onRefresh = { viewModel.loadAll(forceRefresh = true) },
|
||||
actions = {
|
||||
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.saveConfig() }) {
|
||||
|
||||
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
|
||||
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -50,35 +54,114 @@ class ConfigViewModel :
|
||||
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val pendingChanges = mutableMapOf<String, JsonElement>()
|
||||
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
|
||||
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and issue a duplicate loadAll().
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll() {
|
||||
viewModelScope.launch {
|
||||
private fun onDataScopeChanged(newScope: DataScope?) {
|
||||
pendingChanges.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
values = null,
|
||||
schema = null,
|
||||
defaults = null,
|
||||
uncoveredPaths = emptyList(),
|
||||
path = null,
|
||||
yamlText = null,
|
||||
modifiedKeys = emptySet(),
|
||||
isSaving = false,
|
||||
yamlIsSaving = false,
|
||||
yamlIsLoading = false,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
// A scope switch must load the new context. The scoped caches mean this paints the
|
||||
// new scope's own entry instantly when one exists, then revalidates in the background.
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll(forceRefresh: Boolean = false) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
|
||||
if (forceRefresh && scopeKey.isNotBlank()) {
|
||||
schemaCache.remove(scopeKey)
|
||||
defaultsCache.remove(scopeKey)
|
||||
}
|
||||
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
|
||||
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
|
||||
val hasCachedData = _uiState.value.values != null
|
||||
if (!hasCachedData) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
|
||||
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
val schemaDeferred =
|
||||
if (cachedSchema == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val defaultsDeferred =
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
if (cachedDefaults == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
|
||||
|
||||
val configResult = configDeferred.await()
|
||||
val schemaResult = schemaDeferred.await()
|
||||
val defaultsResult = defaultsDeferred.await()
|
||||
val schema: ConfigSchemaResponse? =
|
||||
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
|
||||
} ?: cachedSchema
|
||||
val defaults: Map<String, JsonElement>? =
|
||||
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
|
||||
} ?: cachedDefaults
|
||||
val rawResult = rawDeferred.await()
|
||||
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@coroutineScope
|
||||
|
||||
if (configResult is NetworkResult.Success) {
|
||||
val values = flattenConfig(configResult.data)
|
||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
_uiState.update { state ->
|
||||
val categories = schema?.category_order ?: emptyList()
|
||||
val validCategory =
|
||||
when {
|
||||
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
|
||||
state.activeCategory
|
||||
}
|
||||
|
||||
categories.isNotEmpty() -> {
|
||||
categories.first()
|
||||
}
|
||||
|
||||
else -> {
|
||||
state.activeCategory
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
values = values,
|
||||
schema = schema,
|
||||
@@ -89,15 +172,11 @@ class ConfigViewModel :
|
||||
schema?.fields?.keys ?: emptySet(),
|
||||
),
|
||||
path = path,
|
||||
activeCategory =
|
||||
if (schema?.category_order?.isNotEmpty() == true) {
|
||||
schema.category_order.first()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
activeCategory = validCategory,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (!hasCachedData) {
|
||||
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
|
||||
@@ -105,8 +184,11 @@ class ConfigViewModel :
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +280,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveConfig() {
|
||||
if (pendingChanges.isEmpty()) return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(isSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val changeset = buildChangeset(pendingChanges)
|
||||
@@ -209,6 +292,8 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
pendingChanges.clear()
|
||||
@@ -216,6 +301,8 @@ class ConfigViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
@@ -242,6 +329,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveYamlConfig() {
|
||||
val yamlText = _uiState.value.yamlText ?: return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(yamlIsSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
@@ -252,12 +340,16 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val configResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
yamlIsSaving = false,
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
@@ -82,10 +83,12 @@ fun CronJobsScreen(
|
||||
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
var selectedJob by remember { mutableStateOf<CronJob?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadCronJobs()
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ fun CronJobsScreen(
|
||||
title = { Text(stringResource(R.string.screen_cron)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadCronJobs() },
|
||||
onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.openNewJobDialog() }) {
|
||||
Icon(
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
@@ -16,6 +19,7 @@ import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -95,6 +99,19 @@ class CronJobsViewModel :
|
||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private val jobsCache = SwrCache<String, List<CronJob>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
jobs = emptyList(),
|
||||
errorMessage = null,
|
||||
deleteTarget = null,
|
||||
editorState = CronJobEditorState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Issue #784: gateway broadcasts cron.changed — refresh the list
|
||||
@@ -102,14 +119,24 @@ class CronJobsViewModel :
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.CRON,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
|
||||
onSuccess = { data ->
|
||||
val jobs = data.orEmpty()
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null) jobsCache.put(requestScope.inMemoryKey("default"), jobs)
|
||||
_uiState.update { it.copy(jobs = jobs) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun loadCronJobs() {
|
||||
fun loadCronJobs(forceRefresh: Boolean = false) {
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
safeLaunchSwrLoad(
|
||||
cache = jobsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = loadJob,
|
||||
onCacheHit = { cached ->
|
||||
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -128,6 +155,7 @@ class CronJobsViewModel :
|
||||
|
||||
fun pauseCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
@@ -141,14 +169,24 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
@@ -162,8 +200,17 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +235,7 @@ class CronJobsViewModel :
|
||||
|
||||
fun deleteCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(jobs = state.jobs.filter { it.id != id })
|
||||
}
|
||||
@@ -196,8 +244,17 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +504,7 @@ class CronJobsViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
closeEditor()
|
||||
loadCronJobs()
|
||||
loadCronJobs(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.ManagedFileEntry
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -79,6 +81,7 @@ fun FilesScreen(
|
||||
viewModel: FilesViewModel = viewModel { FilesViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
val uploadLauncher =
|
||||
@@ -104,6 +107,13 @@ fun FilesScreen(
|
||||
onDispose { viewModel.clearTransientState() }
|
||||
}
|
||||
|
||||
// Scope-aware load: a new server/profile context must never keep the old
|
||||
// server's directory path or entries on screen (PR #1192 cache scoping).
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
val hasEntries = state.entries.isNotEmpty()
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
|
||||
import android.util.Base64
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
|
||||
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
|
||||
import com.m57.hermescontrol.data.model.ManagedFileDelete
|
||||
import com.m57.hermescontrol.data.model.ManagedFileEntry
|
||||
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -76,8 +79,52 @@ class FilesViewModel :
|
||||
private val _uiState = MutableStateFlow(FilesUiState())
|
||||
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun load(path: String? = null) {
|
||||
safeLaunchLoad(
|
||||
private val filesCache = SwrCache<String, ManagedFilesListResponse>()
|
||||
|
||||
/**
|
||||
* Reset state owned by the previous server/profile context (issue: PR #1192 cache scoping).
|
||||
* The current directory path belongs to the old server and must not leak into the new one.
|
||||
*/
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentPath = "",
|
||||
parentPath = null,
|
||||
crumbs = emptyList(),
|
||||
entries = emptyList(),
|
||||
isLoading = false,
|
||||
errorMessage = null,
|
||||
busyPaths = emptySet(),
|
||||
isCreatingDir = false,
|
||||
newDirName = "",
|
||||
isUploading = false,
|
||||
deleteTarget = null,
|
||||
openingPath = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(
|
||||
path: String? = null,
|
||||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
val cacheKey = path ?: ""
|
||||
safeLaunchSwrLoad(
|
||||
cache = filesCache,
|
||||
cacheKey = cacheKey,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentPath = data.path,
|
||||
parentPath = data.parent,
|
||||
crumbs = buildCrumbs(data.path),
|
||||
entries = data.entries,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = {
|
||||
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
|
||||
},
|
||||
@@ -104,7 +151,7 @@ class FilesViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
|
||||
fun refresh() = load(_uiState.value.currentPath.ifBlank { null }, forceRefresh = true)
|
||||
|
||||
fun navigateTo(entry: ManagedFileEntry) {
|
||||
if (!entry.isDirectory) return
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -51,9 +52,11 @@ fun GatewayScreen(
|
||||
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadStatus()
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ fun GatewayScreen(
|
||||
title = { Text(stringResource(R.string.screen_gateway)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadStatus() },
|
||||
onRefresh = { viewModel.loadStatus(forceRefresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.status == null -> {
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -28,8 +30,19 @@ class GatewayViewModel :
|
||||
private val _uiState = MutableStateFlow(GatewayUiState())
|
||||
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadStatus() {
|
||||
safeLaunchLoad(
|
||||
private val statusCache = SwrCache<String, StatusResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, status = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun loadStatus(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = statusCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, status = data, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -73,7 +86,7 @@ class GatewayViewModel :
|
||||
toastMessage = "Gateway ${actionName}ed successfully",
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
loadStatus(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||
import com.m57.hermescontrol.data.model.ImportBoardBody
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||
import com.m57.hermescontrol.data.model.KanbanProject
|
||||
@@ -32,7 +33,10 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -168,34 +172,71 @@ class KanbanViewModel(
|
||||
val savedSlug = preferences.getSelectedBoard(endpoint)
|
||||
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val hasExistingContent = _uiState.value.columns.isNotEmpty() || _uiState.value.tasks.isNotEmpty()
|
||||
if (!hasExistingContent) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
loadProfiles()
|
||||
loadProjects()
|
||||
loadOrchestration()
|
||||
loadModelOptions()
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.getBoards(includeArchived = false)) {
|
||||
is NetworkResult.Success -> {
|
||||
val boards = result.data.boards
|
||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
||||
val targetBoard =
|
||||
boards.find { it.id == previouslySelectedId }
|
||||
?: boards.find { it.id == result.data.current }
|
||||
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||
?: boards.firstOrNull()
|
||||
if (targetBoard != null) {
|
||||
selectBoard(targetBoard)
|
||||
coroutineScope {
|
||||
val prefetchedBoardDeferred =
|
||||
if (previouslySelectedId != null) {
|
||||
async(Dispatchers.IO) {
|
||||
repository.getBoard(
|
||||
board = previouslySelectedId,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||
)
|
||||
when (val result = repository.getBoards(includeArchived = false)) {
|
||||
is NetworkResult.Success -> {
|
||||
val boards = result.data.boards
|
||||
val targetBoard =
|
||||
boards.find { it.id == previouslySelectedId }
|
||||
?: boards.find { it.id == result.data.current }
|
||||
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||
?: boards.firstOrNull()
|
||||
|
||||
_uiState.update { it.copy(boards = boards) }
|
||||
|
||||
if (targetBoard != null) {
|
||||
if (prefetchedBoardDeferred != null && targetBoard.id == previouslySelectedId) {
|
||||
preferences.setSelectedBoard(endpoint, targetBoard.id)
|
||||
val gen = ++currentLoadGen
|
||||
_uiState.update { it.copy(selectedBoard = targetBoard) }
|
||||
val prefetchedResult = prefetchedBoardDeferred.await()
|
||||
applyBoardResult(targetBoard, prefetchedResult, gen)
|
||||
connectEvents(targetBoard)
|
||||
} else {
|
||||
selectBoard(targetBoard)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
selectedBoard = null,
|
||||
columns = emptyList(),
|
||||
tasks = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (!hasExistingContent) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -906,15 +947,11 @@ class KanbanViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardIntoState(
|
||||
private fun applyBoardResult(
|
||||
board: KanbanBoard,
|
||||
gen: Int = currentLoadGen,
|
||||
result: NetworkResult<KanbanBoardResponse>,
|
||||
gen: Int,
|
||||
) {
|
||||
val result =
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
if (gen != currentLoadGen) return
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
@@ -928,6 +965,7 @@ class KanbanViewModel(
|
||||
isLoading = false,
|
||||
columns = body.columns,
|
||||
tasks = allTasks,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -948,6 +986,18 @@ class KanbanViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardIntoState(
|
||||
board: KanbanBoard,
|
||||
gen: Int = currentLoadGen,
|
||||
) {
|
||||
val result =
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
applyBoardResult(board, result, gen)
|
||||
}
|
||||
|
||||
fun showToast(message: String) {
|
||||
_uiState.update { it.copy(toastMessage = message) }
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -114,6 +115,7 @@ fun KeysScreen(
|
||||
viewModel: KeysViewModel = viewModel { KeysViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
|
||||
|
||||
@@ -150,7 +152,8 @@ fun KeysScreen(
|
||||
|
||||
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadKeys()
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ fun KeysScreen(
|
||||
title = { Text(stringResource(R.string.screen_keys)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadKeys() },
|
||||
onRefresh = { viewModel.loadKeys(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.openAddDialog() }) {
|
||||
Icon(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
||||
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,6 +57,19 @@ class KeysViewModel :
|
||||
private val _uiState = MutableStateFlow(KeysUiState())
|
||||
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val keysCache = SwrCache<String, Map<String, EnvVarConfig>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
categories = emptyList(),
|
||||
revealedValues = emptyMap(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCategoryList(
|
||||
envVars: Map<String, EnvVarConfig>,
|
||||
expandedCategories: Set<String> =
|
||||
@@ -89,8 +104,21 @@ class KeysViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun loadKeys() {
|
||||
safeLaunchLoad(
|
||||
fun loadKeys(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = keysCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { envVars ->
|
||||
val currentCategories = _uiState.value.categories
|
||||
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
categories = buildCategoryList(envVars, expandedCategories.keys),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -178,7 +206,7 @@ class KeysViewModel :
|
||||
toastMessage = "Key added successfully",
|
||||
)
|
||||
}
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -223,7 +251,7 @@ class KeysViewModel :
|
||||
toastMessage = "Key deleted successfully",
|
||||
)
|
||||
}
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -281,7 +309,7 @@ class KeysViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -70,6 +71,7 @@ fun LogsScreen(
|
||||
viewModel: LogsViewModel = viewModel { LogsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -115,7 +117,8 @@ fun LogsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadLogs()
|
||||
}
|
||||
|
||||
@@ -146,7 +149,7 @@ fun LogsScreen(
|
||||
title = { Text(stringResource(R.string.screen_logs)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadLogs() },
|
||||
onRefresh = { viewModel.loadLogs(forceRefresh = true) },
|
||||
actions = {
|
||||
// Pause / Resume toggle
|
||||
IconButton(onClick = { pauseScroll = !pauseScroll }) {
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.LogResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -40,43 +42,51 @@ class LogsViewModel :
|
||||
private val _uiState = MutableStateFlow(LogsUiState())
|
||||
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var loadInProgress = false
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var autoRefreshJob: Job? = null
|
||||
private val logsCache = SwrCache<String, LogResponse>()
|
||||
|
||||
fun loadLogs() {
|
||||
if (loadInProgress) return
|
||||
loadInProgress = true
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun loadLogs(forceRefresh: Boolean = false) {
|
||||
val filters = _uiState.value.filters
|
||||
safeLaunchLoad(
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getLogs(
|
||||
file = filters.file,
|
||||
lines = filters.lines,
|
||||
level = filters.level,
|
||||
component = filters.component,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val body = data
|
||||
val logsList = body.lines ?: body.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList) }
|
||||
loadInProgress = false
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load logs: $errorMsg",
|
||||
)
|
||||
}
|
||||
loadInProgress = false
|
||||
},
|
||||
)
|
||||
val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
|
||||
loadJob =
|
||||
safeLaunchSwrLoad(
|
||||
cache = logsCache,
|
||||
cacheKey = cacheKey,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = loadJob,
|
||||
onCacheHit = { data ->
|
||||
val logsList = data.lines ?: data.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
|
||||
},
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getLogs(
|
||||
file = filters.file,
|
||||
lines = filters.lines,
|
||||
level = filters.level,
|
||||
component = filters.component,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val logsList = data.lines ?: data.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load logs: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Update the server-side filters and immediately reload. */
|
||||
|
||||
@@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
@@ -99,6 +100,7 @@ fun McpServersScreen(
|
||||
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
@@ -127,14 +129,17 @@ fun McpServersScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.loadServers() }
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadServers()
|
||||
}
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_mcp_servers)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadServers() },
|
||||
onRefresh = { viewModel.loadServers(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.testAllServers() },
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
@@ -9,11 +10,13 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -69,10 +72,39 @@ class McpServersViewModel :
|
||||
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val mcpCache = SwrCache<String, McpServersResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = emptyList(),
|
||||
serverTestResults = emptyMap(),
|
||||
testingServers = emptySet(),
|
||||
isTestingAll = false,
|
||||
errorMessage = null,
|
||||
activeOAuthFlow = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────
|
||||
|
||||
fun loadServers() {
|
||||
safeLaunchLoad(
|
||||
fun loadServers(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = mcpCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = data.servers.orEmpty(),
|
||||
serverTestResults = emptyMap(),
|
||||
testingServers = emptySet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -247,7 +279,7 @@ class McpServersViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -305,7 +337,9 @@ class McpServersViewModel :
|
||||
toastMessage = msg,
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
if (successCount > 0) {
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +405,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Server '${request.name}' added",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -428,7 +462,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Env var '$key' added",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -453,7 +487,7 @@ class McpServersViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -520,7 +554,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Catalog entry '${entry.name}' installed",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -605,7 +639,7 @@ class McpServersViewModel :
|
||||
toastMessage = "OAuth authorization successful!",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
|
||||
@@ -112,7 +112,7 @@ fun MemoryScreen(
|
||||
title = { Text(stringResource(R.string.screen_memory)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.load() },
|
||||
onRefresh = { viewModel.load(forceRefresh = true) },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
when {
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryResetRequest
|
||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||
@@ -10,6 +13,9 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -39,50 +45,89 @@ class MemoryViewModel :
|
||||
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and issue a duplicate request, so the
|
||||
// coordinator flows remain the single trigger.
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
load(silent = true)
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
load(silent = true)
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(silent: Boolean = false) {
|
||||
private var loadJob: Job? = null
|
||||
private val memoryCache = SwrCache<String, MemoryResponse>()
|
||||
|
||||
private fun onDataScopeChanged(newScope: DataScope?) {
|
||||
loadJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
memory = null,
|
||||
learningGraph = null,
|
||||
resetting = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
// A scope switch must silently reload the new context's data. Clearing alone
|
||||
// would leave the screen empty when it is not the composition performing the load.
|
||||
load(silent = true)
|
||||
}
|
||||
|
||||
fun load(
|
||||
silent: Boolean = false,
|
||||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
if (_uiState.value.isLoading) return
|
||||
if (!silent) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopedKey = requestScope?.inMemoryKey("default")
|
||||
if (forceRefresh && scopedKey != null) memoryCache.remove(scopedKey)
|
||||
val cached = if (!forceRefresh && scopedKey != null) memoryCache.get(scopedKey) else null
|
||||
if (cached != null) {
|
||||
_uiState.update { it.copy(isLoading = false, memory = cached, errorMessage = null) }
|
||||
} else if (!silent) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false, memory = result.data) }
|
||||
}
|
||||
loadJob =
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
|
||||
val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.error.message,
|
||||
)
|
||||
val memoryResult = memoryDeferred.await()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (currentScope != requestScope) return@coroutineScope
|
||||
|
||||
when (memoryResult) {
|
||||
is NetworkResult.Success -> {
|
||||
if (scopedKey != null) memoryCache.put(scopedKey, memoryResult.data)
|
||||
_uiState.update { it.copy(isLoading = false, memory = memoryResult.data) }
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (cached == null || forceRefresh) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = memoryResult.error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val graphResult = graphDeferred.await()
|
||||
if (graphResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(learningGraph = graphResult.data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
loadLearningGraph()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLearningGraph() {
|
||||
viewModelScope.launch {
|
||||
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
|
||||
if (result is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(learningGraph = result.data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetMemory(target: String) {
|
||||
@@ -109,7 +154,7 @@ class MemoryViewModel :
|
||||
}
|
||||
}
|
||||
}
|
||||
load()
|
||||
load(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -98,15 +100,15 @@ fun ModelScreen(
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_models)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
isRefreshing = state.isLoading || state.catalogLoading,
|
||||
onRefresh = { viewModel.loadAll(refresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.providers.isEmpty() -> {
|
||||
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadAll() },
|
||||
@@ -114,7 +116,10 @@ fun ModelScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.providers.isEmpty() -> {
|
||||
!state.isLoading &&
|
||||
!state.catalogLoading &&
|
||||
state.providers.isEmpty() &&
|
||||
state.mainModelProvider.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.model_empty_title),
|
||||
subtitle = stringResource(R.string.model_empty_desc),
|
||||
@@ -191,24 +196,38 @@ fun ModelScreen(
|
||||
}
|
||||
|
||||
// ── Provider List ──
|
||||
items(filteredProviders, key = { it.slug }) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
if (state.catalogLoading && state.providers.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredProviders, key = { it.slug }) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
|
||||
ProviderCard(
|
||||
provider = provider,
|
||||
isExpanded = isExpanded,
|
||||
isCurrent = isCurrent,
|
||||
query = query,
|
||||
activeProfile = state.activeProfile,
|
||||
pinnedModels = state.pinnedModels,
|
||||
onToggleExpand = {
|
||||
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||
},
|
||||
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
|
||||
onPin = { slug, model -> viewModel.pinModel(slug, model) },
|
||||
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
|
||||
)
|
||||
ProviderCard(
|
||||
provider = provider,
|
||||
isExpanded = isExpanded,
|
||||
isCurrent = isCurrent,
|
||||
query = query,
|
||||
activeProfile = state.activeProfile,
|
||||
pinnedModels = state.pinnedModels,
|
||||
onToggleExpand = {
|
||||
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||
},
|
||||
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
|
||||
onPin = { slug, model -> viewModel.pinModel(slug, model) },
|
||||
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +260,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = stringResource(R.string.model_set_main),
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
|
||||
pinnedModels = state.pinnedModels,
|
||||
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||
onSelect = { provider, model ->
|
||||
@@ -267,7 +286,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = stringResource(R.string.model_set_aux, state.auxPickerTask),
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
|
||||
pinnedModels = state.pinnedModels,
|
||||
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||
onSelect = { provider, model ->
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user