diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5a4281eb..e876544a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/README.md b/README.md index d2a72800..b96711a7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5c7e594..010ab018 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/8.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/8.json new file mode 100644 index 00000000..099208f0 --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/8.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/player-tone.wav b/app/src/androidTest/assets/player-tone.wav new file mode 100644 index 00000000..38cd2632 Binary files /dev/null and b/app/src/androidTest/assets/player-tone.wav differ diff --git a/app/src/androidTest/assets/player-video.mp4 b/app/src/androidTest/assets/player-video.mp4 new file mode 100644 index 00000000..bdc10396 Binary files /dev/null and b/app/src/androidTest/assets/player-video.mp4 differ diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/ws/StreamingEventDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/StreamingEventDeviceTest.kt new file mode 100644 index 00000000..27a1073f --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/StreamingEventDeviceTest.kt @@ -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(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( + "type" to JsonPrimitive("reasoning.delta"), + "payload" to JsonObject(mapOf("text" to JsonPrimitive("Device streaming verified"))), + ) + val params = + JsonObject( + object : Map by fields { + override val entries: Set> + 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() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt new file mode 100644 index 00000000..946125fc --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt @@ -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) -> 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(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(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(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(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(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 }, + ) + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt new file mode 100644 index 00000000..49617fb4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt @@ -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(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() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 19530c0d..2da52438 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -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() + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt new file mode 100644 index 00000000..796c9a5e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt @@ -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") + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt new file mode 100644 index 00000000..f2c62e86 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt @@ -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() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SearchResultCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SearchResultCardTest.kt new file mode 100644 index 00000000..aa64f7e3 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SearchResultCardTest.kt @@ -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() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt new file mode 100644 index 00000000..7a36421d --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt @@ -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" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index c7241614..5e75e555 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 794a8ee2..5523362d 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt index a82b51de..a7cf427f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStore.kt @@ -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, private val scope: CoroutineScope, + initial: ServerStoreState, ) { - private val _stateFlow: MutableStateFlow - val stateFlow: StateFlow + private val _stateFlow = MutableStateFlow(initial) + val stateFlow: StateFlow = _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, + scope: CoroutineScope, + ): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed()) } fun getLatestState(): ServerStoreState = _stateFlow.value diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 097094b1..f069698d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 1e23a503..f8c0c98c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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 = _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 = + 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.asStateFlow() + + @Volatile + private var initialization: Deferred? = 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(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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index 548e45b5..3a4d6f18 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index 2600557b..953c093d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt b/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt new file mode 100644 index 00000000..35a40579 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index ab12e8b5..f3525f14 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt new file mode 100644 index 00000000..fadaf9a8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt @@ -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(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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt b/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt new file mode 100644 index 00000000..bcfb59ff --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt @@ -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( + private val maxCapacity: Int = DEFAULT_MAX_CAPACITY, + private val ttlMillis: Long = DEFAULT_TTL_MILLIS, + private val timeProvider: () -> Long = { System.currentTimeMillis() }, +) { + private data class Entry( + val value: V, + val timestamp: Long, + ) + + private val lock = Any() + private val map = + object : LinkedHashMap>(maxCapacity, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index 79e1f095..f9b2bf17 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt index ebbdf4c8..6b01d957 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt @@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable @Serializable data class SessionSearchResponse( val results: List = 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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt index 705b2195..d27b9e03 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index be915019..ed330bfa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -277,6 +277,7 @@ object GatewayFileClient { coroutineContext.ensureActive() val read = input.read(buffer) if (read < 0) break + coroutineContext.ensureActive() output.write(buffer, 0, read) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index d8592605..4157489c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 @GET("api/sessions/{id}/messages") diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt new file mode 100644 index 00000000..08a23ba5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt index c0326e7b..66cce620 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkMonitor.kt @@ -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(extraBufferCapacity = Int.MAX_VALUE) internal val networkChanges: SharedFlow = networkChangeEvents.asSharedFlow() + // Emitted when default network transports change without onLost/onAvailable (issue #1165). + private val transportChangeEvents = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + internal val transportChanges: SharedFlow = transportChangeEvents.asSharedFlow() + private val callbackLock = Any() private val callbackGeneration = AtomicInteger(0) private var callbackRegistration: Pair? = null @@ -26,6 +33,9 @@ object NetworkMonitor { @Volatile private var defaultNetwork: Network? = null + @Volatile + private var currentTransports: Set? = 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 { + if (capabilities == null) return emptySet() + val transports = mutableSetOf() + 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 } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 6512ec99..a19c8281 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -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 safeApiCall( } val backoff = jitteredBackoff(500L * (1 shl attempt)) delay(backoff) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { return NetworkResult.Failure( NetworkError.Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt index 7fe39ad8..69c0a063 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt @@ -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(extraBufferCapacity = 1) val switched: SharedFlow = _switched.asSharedFlow() @@ -39,7 +52,7 @@ object ProfileSwitchCoordinator { suspend fun switchProfile(name: String): NetworkResult { 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 → diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index 5dcf78b6..a6887ba3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -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 = 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? { - 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, + 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, + val prerelease: List, +) + +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? { */ 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(json) }.getOrNull() + +/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */ +fun parseReleaseList(json: String): List? = + runCatching { + OkHttpProvider.json.decodeFromString>(json) + }.getOrNull() diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt index 28dc8aae..b4ae9020 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt @@ -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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 3306955b..bbad8a29 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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 ?: return WsEvent.Unknown(rawJson) + val params = jsonParams.toAny() as Map 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" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 65d3ecc8..ab3fa379 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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() 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() private val queuedMessagesById = ConcurrentHashMap() 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 = emptyMap(), onSent: ((String) -> Unit)? = null, + ): String = send(method, params, onSent, true) + + private fun send( + method: String, + params: Map, + 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 - 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 || diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt index 796df16c..489ac929 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt index 2551a208..6b5f4cd6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt new file mode 100644 index 00000000..90cd65b8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt @@ -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) -> 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 = { refresh -> + safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } + }, +) { + suspend fun load(refresh: Boolean = false): NetworkResult { + 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(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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 237e4558..712d4bd5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -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? = 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() /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index ab8c47bc..bb81bd5c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt new file mode 100644 index 00000000..01ba66a4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt new file mode 100644 index 00000000..693108ad --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index ce7e79ab..831aa535 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index dde82529..b776b055 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ReplyNotificationTracker.kt b/app/src/main/java/com/m57/hermescontrol/notification/ReplyNotificationTracker.kt new file mode 100644 index 00000000..b6009916 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/ReplyNotificationTracker.kt @@ -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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/notification/TurnCorrelationTracker.kt b/app/src/main/java/com/m57/hermescontrol/notification/TurnCorrelationTracker.kt new file mode 100644 index 00000000..8c3d8656 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/TurnCorrelationTracker.kt @@ -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 + + fun write(boundaries: List) +} + +/** + * 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 = + prefs.all.mapNotNull { (key, value) -> decodeBoundary(key, value as? String) } + + override fun write(boundaries: List) { + 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 = 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, + now: Long = System.currentTimeMillis(), + ): List = + 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) { + 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? { + 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) }) diff --git a/app/src/main/java/com/m57/hermescontrol/notification/TurnRowResolver.kt b/app/src/main/java/com/m57/hermescontrol/notification/TurnRowResolver.kt new file mode 100644 index 00000000..075ccef7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/TurnRowResolver.kt @@ -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?, + /** Attempt delays; the first attempt runs immediately. */ + private val retryDelaysMs: List = 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? = + 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, +) { + 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 }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt index a085a9eb..3abc84c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt index a01a8388..fed146df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt @@ -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 = _uiState.asStateFlow() private val api get() = ApiClient.hermesApi + private val achievementsCache = SwrCache() - 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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index d261d1d2..124af95b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -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? = 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? = 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(jsonElement) + if (!resp.profiles.isNullOrEmpty()) { + profilesWithMeta = resp.profiles + } } - if (jsonElement != null) { - val resp = OkHttpProvider.json.decodeFromJsonElement(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, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index eb10325e..8d6e7fd5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 2923f3cb..106360c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt index ec4b6d19..73619f6c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt @@ -24,7 +24,8 @@ object ChatInputPolicy { text: String, pendingAttachments: List, 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 783ddba0..3f48a358 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -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, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index be0a197a..a2acc530 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -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 = - 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 671b5f37..287ce2fb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index 2ef7269b..b536fdae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -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, + isPagingOlder: Boolean = false, + context: android.content.Context? = null, ): List { 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() + 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, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 7a69978b..f0029c79 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 = 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) { + 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?) { + // 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 ?: return - val runtimeId = resultMap["session_id"] as? String ?: return + val resultMap = result as? Map + 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 - 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 + val infoMap = resultMap["info"] as? Map 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, @@ -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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 5b2f047b..53c1b1da 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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() + 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() 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, + 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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt new file mode 100644 index 00000000..a1b1ab28 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt @@ -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//` 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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt new file mode 100644 index 00000000..423971ab --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt @@ -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 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 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 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 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 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 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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt index bfa16d3b..227447d8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -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> = 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> = "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=` 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 6b1fff67..ce0f49b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index c3d1a9b0..67efc277 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -80,6 +80,8 @@ fun ChatInputBar( isAgentTyping: Boolean, isConnected: Boolean, commandCatalog: CommandCatalog, + isSessionReady: Boolean, + sessionPreparationFailed: Boolean = false, slashUsageCounts: Map = emptyMap(), pendingAttachments: List = 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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index a45f6b12..e8cfd3bc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -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> { + 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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserver.kt new file mode 100644 index 00000000..99e3c68b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserver.kt @@ -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, + ): List { + 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, + ): List = findVisibleAssistantMessages(layoutInfo, messages.associateBy { it.id }) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index aab2a2f4..ac1de39d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -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:" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt new file mode 100644 index 00000000..972f4399 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt index a7ea4335..15ed878c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt @@ -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` */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt new file mode 100644 index 00000000..b8666b08 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt @@ -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), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index c4bf9e2e..a66cfb6f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -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), ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt index c6612817..429b5230 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt @@ -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(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() - 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, - ) - } - } - } - } - } + ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt index 21ef8f3d..ee562d78 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt @@ -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 + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt index ebc7b4c1..09a23609 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 14ae55fe..d4b436fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -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()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index eb8501c2..e053079d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt index 2366a69e..0937962c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt @@ -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" }}" } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt index e8311ea7..0d137c92 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt @@ -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" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 56d2473e..6a6cd8b4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -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 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 ViewModel.safeLaunchSwrLoad( + cache: SwrCache, + 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, + 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 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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 3291ef4a..213ee6a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -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() }) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt index 29141d4b..4e85d684 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -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 = _uiState.asStateFlow() private val pendingChanges = mutableMapOf() + private val schemaCache = SwrCache(maxCapacity = 10) + private val defaultsCache = SwrCache>(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? = + (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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 7e408b72..ddbc7235 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -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(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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 7049162c..de760d43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 = _uiState.asStateFlow() private var loadJob: Job? = null + private val jobsCache = SwrCache>() + + 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt index 22d98316..448b6497 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesScreen.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt index 6b58ca84..6c25d360 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/files/FilesViewModel.kt @@ -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 = _uiState.asStateFlow() - fun load(path: String? = null) { - safeLaunchLoad( + private val filesCache = SwrCache() + + /** + * 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt index 8fdd09d0..2114b74e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt index 7f865b07..006bbe3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt @@ -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 = _uiState.asStateFlow() - fun loadStatus() { - safeLaunchLoad( + private val statusCache = SwrCache() + + 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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 5c7d38c9..d11c98fd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -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, + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index dde8ab3f..d5177b20 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index 9af30639..11d11030 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -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 = _uiState.asStateFlow() + private val keysCache = SwrCache>() + + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + categories = emptyList(), + revealedValues = emptyMap(), + errorMessage = null, + ) + } + } + private fun buildCategoryList( envVars: Map, expandedCategories: Set = @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index 7fab44de..43d80dfc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -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 }) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index e50663d6..0ca598a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -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 = _uiState.asStateFlow() - @Volatile - private var loadInProgress = false - + private var loadJob: Job? = null private var autoRefreshJob: Job? = null + private val logsCache = SwrCache() - 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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index df7e9748..cc79bb43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -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() }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 3c64bacd..ec585b4d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -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 = _uiState.asStateFlow() + private val mcpCache = SwrCache() + + 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" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index a8d9d5ce..a6d30316 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt index 1ec57e4f..9590a9a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt @@ -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 = _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() + + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index b9790328..fa497374 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -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 -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index c3f67177..fd8ae68b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileModelRequest 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.ModelOptionsRepository import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -41,6 +42,7 @@ val AUX_TASKS = data class ModelUiState( val isLoading: Boolean = false, + val catalogLoading: Boolean = false, val providers: List = emptyList(), val activeProfile: ProfileInfo? = null, val errorMessage: String? = null, @@ -66,8 +68,9 @@ data class ModelUiState( val pendingModelPickerResolve: ((Boolean) -> Unit)? = null, ) -class ModelViewModel : - ViewModel(), +class ModelViewModel( + private val modelOptionsRepository: ModelOptionsRepository = ModelOptionsRepository(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(ModelUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -77,7 +80,7 @@ class ModelViewModel : } fun loadAll(refresh: Boolean = false) { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) } viewModelScope.launch { // Phase 1: Launch fast lightweight calls first (profiles, aux, moa) val activeProfileDeferred = @@ -100,7 +103,7 @@ class ModelViewModel : // Phase 2: Launch model options concurrently val optionsDeferred = async(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } + modelOptionsRepository.load(refresh) } // Await Phase 1 calls (~40-190ms total) and update UI immediately with available state @@ -143,6 +146,7 @@ class ModelViewModel : // Render Phase 1 results right away so UI controls update without waiting for model options _uiState.update { it.copy( + isLoading = false, activeProfile = activeProfile ?: it.activeProfile, pinnedModels = AuthManager.getPinnedModels(), mainModelProvider = mainModel.first, @@ -158,7 +162,7 @@ class ModelViewModel : is NetworkResult.Success -> { _uiState.update { it.copy( - isLoading = false, + catalogLoading = false, providers = optionsResult.data.providers.orEmpty(), ) } @@ -167,9 +171,9 @@ class ModelViewModel : is NetworkResult.Failure -> { _uiState.update { it.copy( - isLoading = false, + catalogLoading = false, errorMessage = - if (it.providers.isEmpty()) { + if (it.providers.isEmpty() && it.mainModelProvider.isEmpty()) { "Failed to load model options: ${optionsResult.error.message}" } else { null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt index 520183b8..edd8e261 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -33,6 +33,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.PairingItem import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState @@ -50,8 +51,10 @@ fun PairingScreen( viewModel: PairingViewModel = viewModel { PairingViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() viewModel.loadPairing() } @@ -61,7 +64,7 @@ fun PairingScreen( title = { Text(stringResource(R.string.screen_pairing)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadPairing() }, + onRefresh = { viewModel.loadPairing(forceRefresh = true) }, modifier = modifier, ) { paddingValues -> val pairing = state.pairing diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt index bee3da57..f4fb5d3c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.pairing import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest @@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.ws.ChangeEvents 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 @@ -37,6 +40,11 @@ class PairingViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var launchJob: Job? = null + private val pairingCache = SwrCache() + + fun clearScopeOwnedState() { + _uiState.update { it.copy(isLoading = false, pairing = null, errorMessage = null, actionKey = null) } + } init { // Issue #784: gateway broadcasts pairing.changed — refresh silently @@ -44,14 +52,23 @@ class PairingViewModel : refreshOnChange( eventType = ChangeEvents.PAIRING, apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } }, - onSuccess = { data -> _uiState.update { it.copy(pairing = data) } }, + onSuccess = { data -> + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestScope != null) pairingCache.put(requestScope.inMemoryKey("default"), data) + _uiState.update { it.copy(pairing = data) } + }, ) } - fun loadPairing() { + fun loadPairing(forceRefresh: Boolean = false) { launchJob = - safeLaunchLoad( + safeLaunchSwrLoad( + cache = pairingCache, + forceRefresh = forceRefresh, currentJob = launchJob, + onCacheHit = { cached -> + _uiState.update { it.copy(isLoading = false, pairing = cached, errorMessage = null) } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -127,7 +144,7 @@ class PairingViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { onSuccess(it.copy(actionKey = null)) } - loadPairing() + loadPairing(forceRefresh = true) } is NetworkResult.Failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9b391987..9d2bca7b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.MemoryProviderDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -75,11 +76,17 @@ fun PluginsScreen( viewModel: PluginsViewModel = viewModel { PluginsViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } var showDetail by remember { mutableStateOf(null) } var showCatalogDetail by remember { mutableStateOf(null) } + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() + viewModel.loadPlugins() + } + val filteredPlugins = remember(query, state.plugins) { state.plugins.filter { plugin -> @@ -111,10 +118,6 @@ fun PluginsScreen( } } - LaunchedEffect(Unit) { - viewModel.loadPlugins() - } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) // Remove confirmation dialog @@ -152,7 +155,7 @@ fun PluginsScreen( isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading, onRefresh = { if (state.selectedTab == PluginsTab.INSTALLED) { - viewModel.loadPlugins() + viewModel.loadPlugins(forceRefresh = true) } else { viewModel.loadCatalog(isRefresh = true) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index d61507bb..bcbc4e6b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -2,10 +2,14 @@ package com.m57.hermescontrol.ui.plugins 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.AgentPluginInstallBody import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest +import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -13,6 +17,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction 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 @@ -73,8 +78,56 @@ class PluginsViewModel : private val _uiState = MutableStateFlow(PluginsUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadPlugins() { - safeLaunchLoad( + private val pluginsCache = SwrCache() + + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + plugins = emptyList(), + orphanPlugins = emptyList(), + errorMessage = null, + memoryProvider = "", + memoryOptions = emptyList(), + contextEngine = "compressor", + contextOptions = emptyList(), + providerBusy = false, + rowBusy = null, + removeConfirmPlugin = null, + rescanBusy = false, + ) + } + } + + fun loadPlugins(forceRefresh: Boolean = false) { + safeLaunchSwrLoad( + cache = pluginsCache, + forceRefresh = forceRefresh, + onCacheHit = { data -> + val plugins = data.plugins.orEmpty() + val providers = data.providers + _uiState.update { + it.copy( + isLoading = false, + plugins = plugins, + orphanPlugins = + data.orphanDashboardPlugins.orEmpty().map { orphan -> + PluginInfo( + name = orphan.name ?: "unknown", + description = orphan.description ?: orphan.label, + version = null, + source = null, + runtimeStatus = null, + ) + }, + memoryProvider = providers?.memoryProvider ?: "", + memoryOptions = providers?.memoryOptions.orEmpty(), + contextEngine = providers?.contextEngine ?: "compressor", + contextOptions = providers?.contextOptions.orEmpty(), + errorMessage = null, + ) + } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getPlugins() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> @@ -85,10 +138,10 @@ class PluginsViewModel : isLoading = false, plugins = plugins, orphanPlugins = - data.orphanDashboardPlugins.orEmpty().map { + data.orphanDashboardPlugins.orEmpty().map { orphan -> PluginInfo( - name = it.name ?: "unknown", - description = it.description ?: it.label, + name = orphan.name ?: "unknown", + description = orphan.description ?: orphan.label, version = null, source = null, runtimeStatus = null, @@ -153,7 +206,7 @@ class PluginsViewModel : toastMessage = "Plugin installed successfully", ) } - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -194,7 +247,7 @@ class PluginsViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") } - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -219,7 +272,7 @@ class PluginsViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") } - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -237,6 +290,7 @@ class PluginsViewModel : fun togglePlugin(plugin: PluginInfo) { val originalEnabled = plugin.enabled val targetEnabled = !originalEnabled + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() // Optimistically update _uiState.update { state -> @@ -261,8 +315,19 @@ class PluginsViewModel : safeApiCall { ApiClient.hermesApi.disablePlugin(plugin.name) } } } - if (result is NetworkResult.Failure) { + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestScope != null && currentScope != requestScope) return@launch + if (result is NetworkResult.Success) { + val updated = _uiState.value.plugins + if (requestScope != null) { + pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = updated)) + } + } else if (result is NetworkResult.Failure) { revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${result.error.message}") + val reverted = _uiState.value.plugins + if (requestScope != null) { + pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = reverted)) + } } } } @@ -276,7 +341,7 @@ class PluginsViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -309,7 +374,7 @@ class PluginsViewModel : is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } clearRowBusy(name) - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -331,7 +396,7 @@ class PluginsViewModel : is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } clearRowBusy(name) - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -359,7 +424,7 @@ class PluginsViewModel : is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin visibility updated") } clearRowBusy(plugin.name) - loadPlugins() + loadPlugins(forceRefresh = true) } is NetworkResult.Failure -> { @@ -469,7 +534,7 @@ class PluginsViewModel : toastMessage = "Plugin \"${entry.displayName}\" installed successfully", ) } - loadPlugins() + loadPlugins(forceRefresh = true) loadCatalog(isRefresh = true) }, onError = { errorMsg -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 468888b7..62fb5dd9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -64,6 +64,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.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -83,6 +84,7 @@ fun ProfilesScreen( viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() var soulEditProfileName by remember { mutableStateOf(null) } var modelEditProfileName by remember { mutableStateOf(null) } @@ -106,7 +108,8 @@ fun ProfilesScreen( var isBuildingProfile by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() viewModel.loadProfiles() } @@ -133,7 +136,7 @@ fun ProfilesScreen( if (isBuildingProfile) { null } else { - { viewModel.loadProfiles() } + { viewModel.loadProfiles(forceRefresh = true) } }, actions = { if (!isBuildingProfile) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index b48fc8ee..2dcf7823 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -3,12 +3,15 @@ package com.m57.hermescontrol.ui.profiles 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.CreateProfileRequest import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.RenameProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest @@ -112,8 +115,53 @@ class ProfilesViewModel( } } - fun loadProfiles() { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + private val profilesCache = SwrCache() + + /** + * Reset state owned by the previous server/profile context (PR #1192 cache scoping). + * Profiles, their soul content, setup commands, and builder data all belong to the + * server that served them and must not be shown for a new context. + */ + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + profiles = emptyList(), + activeProfileName = null, + sharedGatewayProfiles = emptyList(), + selectedSoulContent = null, + isLoadingSoul = false, + errorMessage = null, + isAutoDescribing = false, + setupCommand = null, + isLoadingSetupCommand = false, + modelPickerPinned = emptyList(), + modelProviders = emptyList(), + isLoadingBuilderData = false, + availableSkills = emptyList(), + hubSearchResults = emptyList(), + isSearchingHub = false, + ) + } + } + + fun loadProfiles(forceRefresh: Boolean = false) { + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + val scopedKey = requestScope?.inMemoryKey("default") + if (forceRefresh && scopedKey != null) profilesCache.remove(scopedKey) + val cached = if (!forceRefresh && scopedKey != null) profilesCache.get(scopedKey) else null + if (cached != null) { + _uiState.update { + it.copy( + isLoading = false, + profiles = cached.profiles.orEmpty(), + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), + errorMessage = null, + ) + } + } else { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + } viewModelScope.launch { try { coroutineScope { @@ -125,7 +173,11 @@ class ProfilesViewModel( val profilesResult = profilesDeferred.await() val activeResult = activeDeferred.await() + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (currentScope != requestScope) return@coroutineScope + if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) { + if (scopedKey != null) profilesCache.put(scopedKey, profilesResult.data) _uiState.update { it.copy( isLoading = false, @@ -133,6 +185,7 @@ class ProfilesViewModel( activeProfileName = activeResult.data.active, sharedGatewayProfiles = emptyList(), hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), + errorMessage = null, ) } @@ -145,7 +198,7 @@ class ProfilesViewModel( ) } } - } else { + } else if (cached == null || forceRefresh) { val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success" val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success" _uiState.update { @@ -159,8 +212,11 @@ class ProfilesViewModel( } } } catch (e: Exception) { - _uiState.update { - it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}") + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (currentScope == requestScope && (cached == null || forceRefresh)) { + _uiState.update { + it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}") + } } } } @@ -178,7 +234,7 @@ class ProfilesViewModel( when (val result = ProfileSwitchCoordinator.switchProfile(name)) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Switched to profile $name") } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -244,7 +300,7 @@ class ProfilesViewModel( toastMessage = "Soul updated successfully", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -276,7 +332,7 @@ class ProfilesViewModel( when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Model settings updated") } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -322,7 +378,7 @@ class ProfilesViewModel( toastMessage = "Profile '$sourceProfileName' cloned successfully to '$newProfileName'", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -359,7 +415,7 @@ class ProfilesViewModel( toastMessage = "Profile '$profileName' description updated", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -397,7 +453,7 @@ class ProfilesViewModel( toastMessage = "Profile '$oldName' renamed to '$newName'", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -426,7 +482,7 @@ class ProfilesViewModel( toastMessage = "Profile '$name' deleted", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } is NetworkResult.Failure -> { @@ -463,7 +519,7 @@ class ProfilesViewModel( toastMessage = "Auto-described profile '$name'", ) } - loadProfiles() + loadProfiles(forceRefresh = true) } else { // Backend returns generation failures as ok:false + reason, // NOT an HTTP error — surface the reason to the user. @@ -656,7 +712,7 @@ class ProfilesViewModel( toastMessage = "Profile ${request.name} created successfully", ) } - loadProfiles() + loadProfiles(forceRefresh = true) onSuccess() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt index 17b71a86..e1b77bea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt @@ -46,6 +46,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.OAuthProvider import com.m57.hermescontrol.data.model.OAuthStartResponse import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -65,9 +66,13 @@ fun ProvidersScreen( viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(Unit) { viewModel.load() } + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() + viewModel.load() + } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) @@ -98,7 +103,7 @@ fun ProvidersScreen( title = { Text(stringResource(R.string.screen_providers)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.load() }, + onRefresh = { viewModel.load(forceRefresh = true) }, ) { paddingValues -> when { state.isLoading && state.providers.isEmpty() -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt index 98613200..4c183916 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt @@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.providers import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.OAuthPollResponse import com.m57.hermescontrol.data.model.OAuthProvider +import com.m57.hermescontrol.data.model.OAuthProvidersResponse import com.m57.hermescontrol.data.model.OAuthStartResponse import com.m57.hermescontrol.data.model.OAuthSubmitRequest import com.m57.hermescontrol.data.remote.ApiClient @@ -12,6 +14,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.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -66,9 +69,35 @@ class ProvidersViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var pollJob: Job? = null + private val providersCache = SwrCache() - fun load() { - safeLaunchLoad( + fun clearScopeOwnedState() { + pollJob?.cancel() + _uiState.update { + it.copy( + isLoading = false, + providers = emptyList(), + errorMessage = null, + flowPhase = OAuthFlowPhase.IDLE, + flowProvider = null, + flowErrorMessage = null, + ) + } + } + + fun load(forceRefresh: Boolean = false) { + safeLaunchSwrLoad( + cache = providersCache, + forceRefresh = forceRefresh, + onCacheHit = { data -> + _uiState.update { + it.copy( + isLoading = false, + providers = data.providers.orEmpty(), + errorMessage = null, + ) + } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -105,7 +134,7 @@ class ProvidersViewModel : toastMessage = "Disconnected", ) } - load() + load(forceRefresh = true) }, onError = { error -> _uiState.update { @@ -301,7 +330,7 @@ class ProvidersViewModel : private fun finishFlow(status: String) { pollJob?.cancel() - load() + load(forceRefresh = true) // Stay on DONE so the dialog can show a success confirmation; the Screen's // Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would // conflate with DONE via StateFlow and unmount the dialog before the user sees it.) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt index 1f3d4e41..526c8c9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt @@ -1,5 +1,11 @@ package com.m57.hermescontrol.ui.sessions +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionSearchResult import kotlinx.serialization.json.Json @@ -15,26 +21,27 @@ import java.time.format.FormatStyle import kotlin.math.abs /** - * Converts a backend search hit into a display model WITHOUT faking a title. - * The backend returns no session name, only a matched `snippet` + metadata, so the - * card must render the snippet as a match excerpt (never as the title). `title` is - * deliberately left null so [SearchResultCard] shows the honest "Match" layout. + * Converts a backend search hit into a display model. + * When the backend returns an actual session title, it is mapped cleanly. + * When title is missing/null, title is kept null so [SearchResultCard] shows + * the honest fallback "Match" layout. */ internal fun SessionSearchResult.toSessionInfo(): SessionInfo = SessionInfo( id = session_id, - title = null, + title = title?.takeIf(String::isNotBlank), preview = snippet, source = source, model = model, started_at = session_started, - // message_count/status aren't in the search payload; leave null so the - // search card hides those normal-list affordances. + last_active = last_active, + message_count = message_count, + lineageRootId = lineage_root, ) /** * Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute - * string. Used for search results where the session name is unknown. + * string. Used for search results where the session name is unknown or when displaying recency. */ internal fun formatPlayedAt(epochSeconds: Double?): String? { if (epochSeconds == null || epochSeconds <= 0.0) return null @@ -96,3 +103,84 @@ private fun JsonElement.searchText(): String? = ?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } } } + +private val SEARCH_TOKEN_REGEX = Regex("\"([^\"]+)\"|(\\S+)") + +/** + * Builds an AnnotatedString highlighting terms from [query] in [text]. + * Supports multi-word queries, quoted phrases ("like this"), case-insensitivity, + * overlapping matches, and Unicode characters safely without offset drift. + * Renders as plain text (no HTML interpretation). + */ +fun highlightSearchText( + text: String, + query: String, + highlightBackground: Color, + highlightForeground: Color, +): AnnotatedString { + if (query.isBlank() || text.isEmpty()) { + return AnnotatedString(text) + } + val terms = + SEARCH_TOKEN_REGEX + .findAll(query) + .mapNotNull { match -> + (match.groups[1]?.value ?: match.groups[2]?.value)?.trim()?.takeIf(String::isNotEmpty) + }.distinct() + .toList() + + if (terms.isEmpty()) { + return AnnotatedString(text) + } + + val ranges = mutableListOf() + for (term in terms) { + var startIndex = 0 + while (startIndex < text.length) { + val found = text.indexOf(term, startIndex = startIndex, ignoreCase = true) + if (found == -1) break + ranges.add(found until (found + term.length)) + startIndex = found + term.length + } + } + + if (ranges.isEmpty()) { + return AnnotatedString(text) + } + + ranges.sortBy { it.first } + val merged = mutableListOf() + var current = ranges[0] + for (i in 1 until ranges.size) { + val next = ranges[i] + if (next.first <= current.last + 1) { + current = current.first..maxOf(current.last, next.last) + } else { + merged.add(current) + current = next + } + } + merged.add(current) + + return buildAnnotatedString { + var cursor = 0 + for (range in merged) { + if (range.first > cursor) { + append(text.substring(cursor, range.first)) + } + withStyle( + SpanStyle( + background = highlightBackground, + color = highlightForeground, + fontWeight = FontWeight.Bold, + ), + ) { + append(text.substring(range.first, range.last + 1)) + } + cursor = range.last + 1 + } + if (cursor < text.length) { + append(text.substring(cursor)) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 87bfd29e..3f00fcb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -468,7 +468,7 @@ fun SessionsScreen( title = { Text(stringResource(R.string.screen_history)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadSessions() }, + onRefresh = { viewModel.loadSessions(forceRefresh = true) }, actions = { IconButton( onClick = { NavigationController.openNewChat() }, @@ -661,6 +661,37 @@ fun SessionsScreen( onDelete = { viewModel.requestDeleteSession(session.id) }, ) } + if (state.searchHasMore) { + item(key = "search_paging_footer") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + state.searchLoadMoreError?.let { error -> + Text(error, color = MaterialTheme.colorScheme.error) + } + TextButton( + onClick = viewModel::loadMoreSearch, + enabled = !state.isLoadingMoreSearch, + modifier = + Modifier.fillMaxWidth().testTag( + "search_load_more", + ), + ) { + if (state.isLoadingMoreSearch) { + CircularProgressIndicator(Modifier.size(20.dp)) + } else { + Text( + stringResource( + if (state.searchLoadMoreError != null) { + R.string.action_retry + } else { + R.string.history_load_more + }, + ), + ) + } + } + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 68b0efc7..30c20414 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -3,16 +3,21 @@ package com.m57.hermescontrol.ui.sessions 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.SessionListCacheStore +import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.ProjectInfo import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionSearchResult 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.data.ws.ChangeEventHub import com.m57.hermescontrol.data.ws.ChangeEvents import com.m57.hermescontrol.data.ws.ConnectionStatus @@ -99,6 +104,10 @@ data class SessionsUiState( val isSearching: Boolean = false, val searchResults: List = emptyList(), val searchError: String? = null, + val isLoadingMoreSearch: Boolean = false, + val searchHasMore: Boolean = false, + val searchNextOffset: Int? = null, + val searchLoadMoreError: String? = null, val showHidden: Boolean = false, val pinnedExpanded: Boolean = true, val liveStatuses: Map = emptyMap(), @@ -136,15 +145,31 @@ class SessionsViewModel( private var liveStatusRefreshPending = false private var generation: Long = 0 private var rawPaginationOffset: Int = 0 + private val sessionsPageCache = SwrCache() 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 reset/refetch History twice. + viewModelScope.launch { + ProfileSwitchCoordinator.switched.collect { + onDataScopeChanged(AuthManager.currentDataScope()) + } + } + viewModelScope.launch { + ProfileSwitchCoordinator.connectionSwitched.collect { + onDataScopeChanged(AuthManager.currentDataScope()) + } + } + // Issue #784: gateway broadcasts sessions.changed — refresh the list // silently (no spinner, no selection reset) instead of blind polling. refreshOnChange( eventType = ChangeEvents.SESSIONS, apiCall = { val requestGeneration = generation - val section = _uiState.value.section + val capturedSection = _uiState.value.section + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() when ( val result = safeApiCall { @@ -152,17 +177,31 @@ class SessionsViewModel( limit = PAGE_SIZE, offset = 0, order = "recent", - source = section.source, - excludeSources = section.excludeSources, + source = capturedSection.source, + excludeSources = capturedSection.excludeSources, ) } ) { - is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data) - is NetworkResult.Failure -> result + is NetworkResult.Success -> { + NetworkResult.Success( + Triple(requestGeneration, Pair(requestScope, capturedSection), result.data), + ) + } + + is NetworkResult.Failure -> { + result + } } }, - onSuccess = { (requestGeneration, data) -> - if (requestGeneration == generation) { + onSuccess = { (requestGeneration, scopeAndSection, data) -> + val (requestScope, capturedSection) = scopeAndSection + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration == generation && currentScope == requestScope) { + val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}" + val inMemoryKey = requestScope?.inMemoryKey(localKey) + val persistentKey = requestScope?.persistentKey(localKey) + if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data) + if (persistentKey != null) SessionListCacheStore.put(persistentKey, data) rawPaginationOffset = data.nextOffset(0) _uiState.update { val newSessions = data.sessions.orEmpty() @@ -185,6 +224,41 @@ class SessionsViewModel( ) } + private fun onDataScopeChanged(newScope: DataScope? = AuthManager.currentDataScope()) { + generation++ + searchGeneration++ + trackingGeneration++ + loadJob?.cancel() + pageJob?.cancel() + statsJob?.cancel() + searchJob?.cancel() + searchPageJob?.cancel() + projectsJob?.cancel() + rawPaginationOffset = 0 + resolvedParentCache.clear() + _uiState.update { + it.copy( + sessions = emptyList(), + total = 0, + hasMore = false, + selectedIds = emptySet(), + isSelecting = false, + errorMessage = null, + searchResults = emptyList(), + searchError = null, + searchLoadMoreError = null, + searchHasMore = false, + searchNextOffset = null, + isSearching = false, + isLoadingMoreSearch = false, + stats = SessionStats(), + emptyCount = 0, + projects = emptyList(), + ) + } + loadSessions() + } + /** * Page size sent to the server. Matches the desktop sidebar's * SIDEBAR_SESSIONS_PAGE_SIZE (50); the backend caps `limit` at 100. @@ -255,6 +329,8 @@ class SessionsViewModel( loadJob?.cancel() pageJob?.cancel() searchJob?.cancel() + searchPageJob?.cancel() + searchGeneration++ rawPaginationOffset = 0 val query = _uiState.value.searchQuery _uiState.update { @@ -277,9 +353,43 @@ class SessionsViewModel( } /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */ - fun loadSessions() { + fun loadSessions(forceRefresh: Boolean = false) { val requestGeneration = generation + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val section = _uiState.value.section + val localKey = "${section.name}:${section.source}:${section.excludeSources}" + val inMemoryKey = requestScope?.inMemoryKey(localKey) + val persistentKey = requestScope?.persistentKey(localKey) + if (forceRefresh) { + if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey) + if (persistentKey != null) SessionListCacheStore.remove(persistentKey) + } + val cached = + if (!forceRefresh && inMemoryKey != null && persistentKey != null) { + sessionsPageCache.get(inMemoryKey) + ?: SessionListCacheStore.get(persistentKey)?.also { sessionsPageCache.put(inMemoryKey, it) } + } else { + null + } + if (cached != null) { + val sessionsList = cached.sessions.orEmpty() + val paging = + SessionsPaging.resolveInitialPaging( + receivedCount = minOf(cached.sessions.size, PAGE_SIZE), + pageSize = PAGE_SIZE, + backendTotal = cached.total, + accumulatedCount = sessionsList.size, + ) + _uiState.update { + it.copy( + isLoading = false, + sessions = sessionsList, + total = paging.total, + hasMore = paging.hasMore, + errorMessage = null, + ) + } + } pageJob?.cancel() loadEmptyCount() if (trackingJob?.isActive == true) { @@ -300,9 +410,16 @@ class SessionsViewModel( ) } }, - onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, + onStart = { + if (cached == null) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + } + }, onSuccess = { data -> - if (requestGeneration != generation) return@safeLaunchLoad + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad + if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data) + if (persistentKey != null) SessionListCacheStore.put(persistentKey, data) rawPaginationOffset = data.nextOffset(0) val sessionsList = data.sessions.orEmpty() val paging = @@ -325,13 +442,15 @@ class SessionsViewModel( stitchMissingParents(requestGeneration) }, onError = { errorMsg -> - if (requestGeneration != generation) return@safeLaunchLoad - _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - errorMessage = "Failed to load sessions: $errorMsg", - ) + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad + if (cached == null || forceRefresh) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load sessions: $errorMsg", + ) + } } }, ) @@ -355,6 +474,7 @@ class SessionsViewModel( val state = _uiState.value if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return val requestGeneration = generation + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val offset = rawPaginationOffset _uiState.update { it.copy(isLoadingMore = true) } @@ -370,7 +490,8 @@ class SessionsViewModel( excludeSources = state.section.excludeSources, ) } - if (requestGeneration != generation) return@launch + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration != generation || currentScope != requestScope) return@launch when (result) { is NetworkResult.Success -> { val data = result.data @@ -413,6 +534,8 @@ class SessionsViewModel( // ── Search (server-backed FTS5) ────────────────────────────────── private var searchJob: Job? = null + private var searchPageJob: Job? = null + private var searchGeneration = 0L /** * Debounced server-side session search. A non-blank query schedules a search @@ -420,53 +543,107 @@ class SessionsViewModel( * paginated list mode. */ fun setSearchQuery(query: String) { - val requestGeneration = generation + val requestGeneration = ++searchGeneration val section = _uiState.value.section - _uiState.update { it.copy(searchQuery = query, searchResults = emptyList()) } searchJob?.cancel() + searchPageJob?.cancel() + _uiState.update { + it.copy( + searchQuery = query, + searchResults = emptyList(), + isSearching = query.isNotBlank(), + searchError = null, + isLoadingMoreSearch = false, + searchHasMore = false, + searchNextOffset = null, + searchLoadMoreError = null, + isSelecting = false, + selectedIds = emptySet(), + ) + } if (query.isBlank()) { - _uiState.update { - it.copy(searchResults = emptyList(), searchError = null, isSearching = false) - } if (_uiState.value.sessions.isEmpty()) loadSessions() return } + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() searchJob = viewModelScope.launch { delay(SEARCH_DEBOUNCE_MS) - _uiState.update { it.copy(isSearching = true, searchError = null) } - val result = - safeApiCall { - ApiClient.hermesApi.searchSessions( - q = query, - profile = null, - source = section.source, - excludeSources = section.excludeSources, - ) - } - if (requestGeneration != generation || _uiState.value.searchQuery != query) return@launch - when (result) { - is NetworkResult.Success -> { - _uiState.update { - it.copy( - isSearching = false, - searchResults = result.data.results.orEmpty(), - searchError = null, - ) - } - } + requestSearchPage(query, section, requestGeneration, requestScope, 0) + } + } - is NetworkResult.Failure -> { - _uiState.update { - it.copy( - isSearching = false, - searchResults = emptyList(), - searchError = "Search failed: ${result.error.message}", - ) + fun loadMoreSearch() { + val state = _uiState.value + val offset = state.searchNextOffset ?: return + if (!state.isSearchMode || state.isSearching || state.isLoadingMoreSearch || !state.searchHasMore) return + val requestGeneration = searchGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + _uiState.update { it.copy(isLoadingMoreSearch = true, searchLoadMoreError = null) } + searchPageJob = + viewModelScope.launch { + requestSearchPage(state.searchQuery, state.section, requestGeneration, requestScope, offset) + } + } + + private suspend fun requestSearchPage( + query: String, + section: HistorySection, + requestGeneration: Long, + requestScope: DataScope?, + offset: Int, + ) { + val result = + safeApiCall { + ApiClient.hermesApi.searchSessions( + q = query, + profile = null, + source = section.source, + excludeSources = section.excludeSources, + limit = 20, + offset = offset, + ) + } + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration != searchGeneration || _uiState.value.section != section || + currentScope != requestScope + ) { + return + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { state -> + val previous = if (offset == 0) emptyList() else state.searchResults + // Dedup by final surfaced session id: legacy backends can return + // the same continuation twice (distinct lineage roots), and + // LazyList keys session ids — duplicates crash the screen. + val results = (previous + result.data.results).distinctBy { it.session_id } + val next = + result.data.next_offset?.takeIf { + result.data.hasMore && it > offset && results.size > previous.size } + state.copy( + isSearching = false, + isLoadingMoreSearch = false, + searchResults = results, + searchError = null, + searchLoadMoreError = null, + searchHasMore = next != null, + searchNextOffset = next, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + if (offset == 0) { + it.copy(isSearching = false, searchError = "Search failed: ${result.error.message}") + } else { + it.copy(isLoadingMoreSearch = false, searchLoadMoreError = "Failed to load more") } } } + } } // ── Stats ──────────────────────────────────────────────────────────── @@ -581,6 +758,10 @@ class SessionsViewModel( it.sessions.map { s -> if (s.id == sessionId) s.copy(title = newTitle) else s }, + searchResults = + it.searchResults.map { s -> + if (s.session_id == sessionId) s.copy(title = newTitle) else s + }, toastMessage = "Session renamed", ) } @@ -780,6 +961,7 @@ class SessionsViewModel( isDeletingBulk = false, isSelecting = false, selectedIds = emptySet(), + searchResults = it.searchResults.filter { hit -> hit.session_id !in ids }, toastMessage = toastMsg, ) } @@ -847,11 +1029,15 @@ class SessionsViewModel( /** Refresh the empty-session count (REST; auxiliary — failures are silent). */ fun loadEmptyCount() { + val requestGeneration = generation + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() viewModelScope.launch { val result = runCatching { safeApiCall { ApiClient.hermesApi.getEmptySessionCount() } }.getOrNull() + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestGeneration != generation || currentScope != requestScope) return@launch val count = (result as? NetworkResult.Success)?.data?.count if (count != null) { _uiState.update { it.copy(emptyCount = count) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt index b5806b9c..283b9b8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -44,11 +44,11 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet import com.m57.hermescontrol.ui.sessions.formatPlayedAt +import com.m57.hermescontrol.ui.sessions.highlightSearchText /** - * Search-result card. The backend search payload has no session title, so this card is - * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus - * source / model / played-at metadata chips. It never presents the snippet as a name. + * Search-result card with a separate title and matched excerpt. Legacy responses + * without a title retain the Match label; a snippet is never used as a name. */ @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable @@ -71,7 +71,7 @@ fun SearchResultCard( val statusColors = LocalHermesStatusColors.current val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) val cleanSnippet = cleanSearchSnippet(snippet) - val playedAt = formatPlayedAt(session.started_at) + val playedAt = formatPlayedAt(session.last_active ?: session.started_at) var menuExpanded by remember { mutableStateOf(false) } Card( @@ -145,9 +145,20 @@ fun SearchResultCard( Spacer(modifier = Modifier.height(spacing.xs)) - // The matched snippet, highlighted — shown as the body, NOT as a title. + session.title?.takeIf(String::isNotBlank)?.let { title -> + Text( + text = highlightSearchText(title, query, highlightBackground, highlightForeground), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("search_title_${session.id}"), + ) + Spacer(modifier = Modifier.height(spacing.xs)) + } + + // The matched snippet stays separate from the conversation title. Text( - text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), + text = highlightSearchText(cleanSnippet, query, highlightBackground, highlightForeground), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 4, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt index 14f6568d..b02aa97b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt @@ -12,9 +12,12 @@ import com.m57.hermescontrol.data.update.AppUpdateCache import com.m57.hermescontrol.data.update.AppUpdateChecker import com.m57.hermescontrol.data.update.AppUpdateState import com.m57.hermescontrol.data.update.isNewerVersion +import com.m57.hermescontrol.data.update.isReleaseCandidateVersion import com.m57.hermescontrol.data.update.releaseTag +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,15 +45,35 @@ class AppUpdateViewModel( private val _state = MutableStateFlow(AppUpdateState.Idle) val state: StateFlow = _state.asStateFlow() + private val _checkReleaseCandidateUpdates = + MutableStateFlow(AuthManager.isCheckingReleaseCandidateUpdates()) + + /** Whether the RC update channel is enabled (issue: RC update toggle). */ + val checkReleaseCandidateUpdates: StateFlow = _checkReleaseCandidateUpdates.asStateFlow() + + private var checkJob: kotlinx.coroutines.Job? = null private var downloadJob: kotlinx.coroutines.Job? = null private var lastAvailable: AppUpdateState.UpdateAvailable? = null + /** + * Bumped by every check. The blocking OkHttp call underneath cannot be + * interrupted, so a cancelled check may still be running; results are only + * published when this token is unchanged (and the channel still matches). + */ + private var checkGeneration = 0 + init { val cached = AppUpdateCache.state.value - if (cached is AppUpdateState.UpdateAvailable) { + // An RC offer cached while the RC channel was on must not be adopted + // after the user switched back to stable-only. + val blocked = + !_checkReleaseCandidateUpdates.value && + cached.releaseTag()?.let(::isReleaseCandidateVersion) == true + if (blocked) AppUpdateCache.reset() + if (cached is AppUpdateState.UpdateAvailable && !blocked) { lastAvailable = cached _state.value = cached - } else if (cached is AppUpdateState.UpToDate) { + } else if (cached is AppUpdateState.UpToDate && !blocked) { _state.value = cached } else { checkForUpdate() @@ -60,56 +83,132 @@ class AppUpdateViewModel( /** Manual check from the About row or dialog. */ fun checkForUpdate() { if (_state.value is AppUpdateState.Checking) return + runCheck() + } + + /** + * Opt in/out of release-candidate updates and immediately re-check on the + * newly selected channel. Switching back to stable drops any cached RC + * offer, and [updateAvailableForInstall] refuses an RC on the stable channel, + * so a stale offer can never be installed. + */ + fun setCheckReleaseCandidateUpdates(enabled: Boolean) { + if (_checkReleaseCandidateUpdates.value == enabled) return + AuthManager.setCheckReleaseCandidateUpdates(enabled) + _checkReleaseCandidateUpdates.value = enabled + if (!enabled) { + lastAvailable = null + AppUpdateCache.reset() + } + // A check already in flight captured the previous channel — cancel it and + // bump the generation so a late result can never be published. + checkJob?.cancel() + runCheck() + } + + private fun runCheck() { + val generation = ++checkGeneration + val includeReleaseCandidates = _checkReleaseCandidateUpdates.value _state.value = AppUpdateState.Checking - viewModelScope.launch(ioDispatcher) { - val now = System.currentTimeMillis() - AuthManager.setUpdateCheckDoneForVersion(currentVersion) - AuthManager.setLastUpdateCheckTimestamp(now) - val result = - try { - checker.fetchLatestRelease() - } catch (e: IOException) { - _state.value = AppUpdateState.Error(NETWORK_ERROR) - return@launch - } catch (e: Exception) { - _state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR) - return@launch - } - val info = - result ?: run { - _state.value = AppUpdateState.Error(NO_RELEASE_ERROR) - return@launch - } - val apk = - info.apkAsset ?: run { - _state.value = AppUpdateState.Error(NO_APK_ERROR) - return@launch - } - _state.value = - if (isNewerVersion(info.tagName, currentVersion)) { - AppUpdateState - .UpdateAvailable( + checkJob = + viewModelScope.launch(ioDispatcher) { + val now = System.currentTimeMillis() + AuthManager.setUpdateCheckDoneForVersion(currentVersion) + AuthManager.setLastUpdateCheckTimestamp(now) + val result = + try { + checker.fetchLatestRelease(includeReleaseCandidates) + } catch (e: CancellationException) { + // Cancellation is control flow — it must never be reported + // as a failed check. + throw e + } catch (e: IOException) { + if (isCurrentCheck(generation, includeReleaseCandidates)) { + _state.value = AppUpdateState.Error(NETWORK_ERROR) + } + return@launch + } catch (e: Exception) { + if (isCurrentCheck(generation, includeReleaseCandidates)) { + _state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR) + } + return@launch + } + + // The OkHttp call underneath blocks and cannot be interrupted, so a + // newer check — or a channel switch — may have happened while it ran. + ensureActive() + if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch + + val info = + result ?: run { + _state.value = AppUpdateState.Error(NO_RELEASE_ERROR) + return@launch + } + val apk = + info.apkAsset ?: run { + _state.value = AppUpdateState.Error(NO_APK_ERROR) + return@launch + } + val state = + if (isNewerVersion(info.tagName, currentVersion)) { + AppUpdateState.UpdateAvailable( latestTag = info.tagName, apkUrl = apk.browserDownloadUrl, sizeBytes = apk.size, releaseNotes = info.body, - ).also { lastAvailable = it } - } else { - AppUpdateState.UpToDate(latestTag = info.tagName) - } - // Keep the launch notice (issue #890) in sync with manual checks. - AppUpdateCache.update(_state.value) - _state.value.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) } - } + ) + } else { + AppUpdateState.UpToDate(latestTag = info.tagName) + } + // Fail closed: a release-candidate result is never published once + // the RC channel is off, however it reached this point. + if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch + if (state is AppUpdateState.UpdateAvailable) lastAvailable = state + _state.value = state + // Keep the launch notice (issue #890) in sync with manual checks. + AppUpdateCache.update(state) + state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) } + } } - /** Download the release APK and launch the system installer. */ - fun startUpdate() { + /** + * True when [generation] is still the newest check *and* the channel that + * check was started for is still the selected one. + */ + private fun isCurrentCheck( + generation: Int, + includeReleaseCandidates: Boolean, + ): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value + + /** A release-candidate tag may only be used while the RC channel is on. */ + private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean = + tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value + + /** + * The update this ViewModel may install right now, or null. + * + * Fails closed: a release-candidate offer is refused while the RC channel is + * off, however it reached held state. A refusal drops the stale offer and + * re-checks on the current channel instead of dead-ending. + */ + private fun updateAvailableForInstall(): AppUpdateState.UpdateAvailable? { val available = (_state.value as? AppUpdateState.UpdateAvailable) ?: lastAvailable ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) - ?: return + ?: return null + if (!isAdmissibleOnCurrentChannel(available.latestTag)) { + lastAvailable = null + AppUpdateCache.reset() + checkForUpdate() + return null + } + return available + } + + /** Download the release APK and launch the system installer. */ + fun startUpdate() { + val available = updateAvailableForInstall() ?: return lastAvailable = available if (!canRequestInstalls()) { _state.value = AppUpdateState.NeedsUnknownSourcesPermission @@ -166,10 +265,7 @@ class AppUpdateViewModel( fun resumeInstallAfterPermission() { if (canRequestInstalls()) { val dest = File(getApplication().cacheDir, APK_FILE_NAME) - val available = - (_state.value as? AppUpdateState.UpdateAvailable) - ?: lastAvailable - ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) + val available = updateAvailableForInstall() if (dest.exists() && dest.length() > 0 && available != null) { _state.value = AppUpdateState.Installing(available.latestTag) launchInstaller(dest) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 7e4d9ce3..6ea47d43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -114,7 +114,9 @@ fun SettingsScreen( icon = Icons.Filled.Tune, label = stringResource(R.string.settings_sec_behavior), summary = - if (state.autoReconnect || state.restoreLastSession) { + if (state.autoReconnect || state.restoreLastSession || + state.keepConnectedInBackground + ) { stringResource(R.string.settings_summary_on) } else { stringResource(R.string.settings_summary_off) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index caaf22df..8d30bfaf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -207,6 +207,8 @@ internal fun SettingsChatPage( onAssistantMessageTokensChange = viewModel::onAssistantMessageTokensChange, showTokensPerSecond = state.showTokensPerSecond, onTokensPerSecondChange = viewModel::onTokensPerSecondChange, + showModelProvider = state.showModelProvider, + onShowModelProviderChange = viewModel::onShowModelProviderChange, ) } } @@ -235,6 +237,8 @@ internal fun SettingsBehaviorPage( BehaviorSection( autoReconnect = state.autoReconnect, onAutoReconnectChange = viewModel::onAutoReconnectChange, + keepConnectedInBackground = state.keepConnectedInBackground, + onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, restoreLastSession = state.restoreLastSession, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) @@ -258,6 +262,7 @@ internal fun SettingsAboutPage( }, ) { val updateState by viewModel.state.collectAsStateWithLifecycle() + val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle() val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current @@ -290,8 +295,10 @@ internal fun SettingsAboutPage( ) { AboutSection( updateState = updateState, + checkReleaseCandidateUpdates = checkReleaseCandidateUpdates, onCheckUpdate = viewModel::checkForUpdate, onStartUpdate = viewModel::startUpdate, + onCheckReleaseCandidateUpdatesChange = viewModel::setCheckReleaseCandidateUpdates, onOpenInstallSettings = { val intent = Intent( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 9f543892..624efb1d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.SessionListCacheStore import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkResult @@ -42,6 +43,8 @@ data class SettingsUiState( val showUserMessageTokens: Boolean = true, val showAssistantMessageTokens: Boolean = true, val showTokensPerSecond: Boolean = true, + val showModelProvider: Boolean = false, + val keepConnectedInBackground: Boolean = false, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -88,6 +91,8 @@ class SettingsViewModel( val showUserMessageTokens = AuthManager.isUserMessageTokensEnabled() val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled() val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() + val showModelProvider = AuthManager.isModelProviderShown() + val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -113,6 +118,8 @@ class SettingsViewModel( showUserMessageTokens = showUserMessageTokens, showAssistantMessageTokens = showAssistantMessageTokens, showTokensPerSecond = showTokensPerSecond, + showModelProvider = showModelProvider, + keepConnectedInBackground = keepConnectedInBackground, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -359,12 +366,23 @@ class SettingsViewModel( AuthManager.setTokensPerSecondEnabled(enabled) } + fun onShowModelProviderChange(enabled: Boolean) { + _uiState.update { it.copy(showModelProvider = enabled, isSaved = false) } + AuthManager.setModelProviderShown(enabled) + } + + fun onKeepConnectedInBackgroundChange(enabled: Boolean) { + _uiState.update { it.copy(keepConnectedInBackground = enabled) } + AuthManager.setKeepConnectedInBackground(enabled) + } + /** Clear all auth credentials — logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) AuthManager.setSessionCookie(null) AuthManager.setWsAuthParam("token") AuthManager.clearLastOpenedSessionId() + SessionListCacheStore.clear() HermesWsClient.disconnect(clearPendingMessages = true) // Don't rebuild ApiClient here — let the navigation complete first } @@ -391,6 +409,8 @@ class SettingsViewModel( AuthManager.setUserMessageTokensEnabled(state.showUserMessageTokens) AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens) AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond) + AuthManager.setModelProviderShown(state.showModelProvider) + AuthManager.setKeepConnectedInBackground(state.keepConnectedInBackground) ApiClient.rebuild() viewModelScope.launch(ioDispatcher) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 2ab8ab54..1a8207f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.settings.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,6 +13,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,8 +31,10 @@ import com.m57.hermescontrol.ui.settings.SectionCard @Composable internal fun AboutSection( updateState: AppUpdateState = AppUpdateState.Idle, + checkReleaseCandidateUpdates: Boolean = false, onCheckUpdate: () -> Unit = {}, onStartUpdate: () -> Unit = {}, + onCheckReleaseCandidateUpdatesChange: (Boolean) -> Unit = {}, onOpenInstallSettings: () -> Unit = {}, ) { SectionCard { @@ -57,6 +62,15 @@ internal fun AboutSection( onStartUpdate = onStartUpdate, onOpenInstallSettings = onOpenInstallSettings, ) + ReleaseCandidateRow( + checked = checkReleaseCandidateUpdates, + // Mid-download / mid-install the channel must not flip under the + // in-flight APK. + enabled = + updateState !is AppUpdateState.Downloading && + updateState !is AppUpdateState.Installing, + onCheckedChange = onCheckReleaseCandidateUpdatesChange, + ) InfoRow( label = stringResource(R.string.settings_about_build), value = @@ -223,3 +237,44 @@ private fun ValueText(text: String) { ), ) } + +/** + * Opt-in switch for pre-release (release candidate) updates. Stable-only is + * the default; enabling this makes the update check consider RC tags too. + */ +@Composable +private fun ReleaseCandidateRow( + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_about_rc_updates_title), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.settings_about_rc_updates_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 43f83481..87d2e175 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -213,6 +213,8 @@ internal fun ChatSection( onAssistantMessageTokensChange: (Boolean) -> Unit = {}, showTokensPerSecond: Boolean = true, onTokensPerSecondChange: (Boolean) -> Unit = {}, + showModelProvider: Boolean = false, + onShowModelProviderChange: (Boolean) -> Unit = {}, ) { val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val currentIndex = @@ -425,6 +427,38 @@ internal fun ChatSection( Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text( + text = stringResource(R.string.settings_item_show_model_provider), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_show_model_provider), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = showModelProvider, + onCheckedChange = onShowModelProviderChange, + modifier = Modifier.testTag("settings_show_model_provider"), + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index 1bb9bd17..7eca2657 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -23,6 +23,8 @@ import com.m57.hermescontrol.ui.settings.SectionCard internal fun BehaviorSection( autoReconnect: Boolean, onAutoReconnectChange: (Boolean) -> Unit, + keepConnectedInBackground: Boolean, + onKeepConnectedInBackgroundChange: (Boolean) -> Unit, restoreLastSession: Boolean, onRestoreLastSessionChange: (Boolean) -> Unit, ) { @@ -60,6 +62,39 @@ internal fun BehaviorSection( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_keep_connected_in_background_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_keep_connected_in_background_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = keepConnectedInBackground, + onCheckedChange = onKeepConnectedInBackgroundChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 9e58ccb2..a339df71 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -25,6 +25,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.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.ToastEffect @@ -43,11 +44,13 @@ fun SkillsScreen( viewModel: SkillsViewModel = viewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) } var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) } - LaunchedEffect(Unit) { + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() viewModel.loadSkills() } @@ -58,7 +61,7 @@ fun SkillsScreen( isRefreshing = state.isLoading, onRefresh = { if (state.viewMode == SkillsViewMode.INSTALLED) { - viewModel.loadSkills() + viewModel.loadSkills(forceRefresh = true) } else { viewModel.loadHubSources() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index a7dc2215..b0a7dda7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -4,6 +4,9 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.R +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.HubSkill import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.Skill @@ -17,6 +20,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.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -93,11 +97,35 @@ class SkillsViewModel( private var loadJob: Job? = null private var searchJob: Job? = null + private val skillsCache = SwrCache>() - fun loadSkills() { + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + skills = emptyList(), + errorMessage = null, + editingSkillName = null, + skillContent = null, + isLoadingContent = false, + isSavingContent = false, + saveContentSuccess = false, + previewSkillName = null, + previewSkillContent = null, + isLoadingPreview = false, + ) + } + } + + fun loadSkills(forceRefresh: Boolean = false) { loadJob = - safeLaunchLoad( + safeLaunchSwrLoad( + cache = skillsCache, + forceRefresh = forceRefresh, currentJob = loadJob, + onCacheHit = { cached -> + _uiState.update { it.copy(isLoading = false, skills = cached, errorMessage = null) } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> @@ -328,7 +356,7 @@ class SkillsViewModel( ) } // Refresh installed skills - loadSkills() + loadSkills(forceRefresh = true) } is NetworkResult.Failure -> { @@ -365,7 +393,7 @@ class SkillsViewModel( toastMessage = "Uninstalled: $name", ) } - loadSkills() + loadSkills(forceRefresh = true) } is NetworkResult.Failure -> { @@ -396,7 +424,9 @@ class SkillsViewModel( fun toggleSkill(skill: Skill) { val originalEnabled = skill.enabled val targetEnabled = !originalEnabled + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + // Optimistically update _uiState.update { state -> state.copy( skills = @@ -411,12 +441,23 @@ class SkillsViewModel( withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } } - if (result is NetworkResult.Failure) { + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestScope != null && currentScope != requestScope) return@launch + if (result is NetworkResult.Success) { + val updated = _uiState.value.skills + if (requestScope != null) { + skillsCache.put(requestScope.scopedKey("default"), updated) + } + } else if (result is NetworkResult.Failure) { revertSkillToggle( skill.name, originalEnabled, "Failed to toggle skill: ${result.error.message}", ) + val reverted = _uiState.value.skills + if (requestScope != null) { + skillsCache.put(requestScope.scopedKey("default"), reverted) + } } } } @@ -525,7 +566,7 @@ class SkillsViewModel( when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Skills updated") } - loadSkills() + loadSkills(forceRefresh = true) } is NetworkResult.Failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 205f4f84..88b8b69e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -154,48 +154,86 @@ class SystemViewModel( val statsResult = statsDeferred.await() val statusResult = statusDeferred.await() - val portalResult = portalDeferred.await() - val curatorResult = curatorDeferred.await() - - val credResult = credDeferred.await() - val checkpointsResult = checkpointsDeferred.await() - val hooksResult = hooksDeferred.await() - val updateResult = updateDeferred.await() - val doctorResult = doctorDeferred.await() - + // Phase 1: Un-gate UI immediately with fast host stats & gateway status (~100-200ms) _uiState.update { state -> state.copy( isLoading = false, - stats = (statsResult as? NetworkResult.Success)?.data, - status = (statusResult as? NetworkResult.Success)?.data, - portal = (portalResult as? NetworkResult.Success)?.data, - curator = (curatorResult as? NetworkResult.Success)?.data, - credentials = - ((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(), - checkpoints = (checkpointsResult as? NetworkResult.Success)?.data, - hooks = (hooksResult as? NetworkResult.Success)?.data, - updateInfo = (updateResult as? NetworkResult.Success)?.data, - doctorReport = (doctorResult as? NetworkResult.Success)?.data, + stats = (statsResult as? NetworkResult.Success)?.data ?: state.stats, + status = (statusResult as? NetworkResult.Success)?.data ?: state.status, errorMessage = null, ) } - // Log failures in debug builds if (BuildConfig.DEBUG) { - listOf( - "stats" to statsResult, - "status" to statusResult, - "portal" to portalResult, - "curator" to curatorResult, - "credentials" to credResult, - "checkpoints" to checkpointsResult, - "hooks" to hooksResult, - "update" to updateResult, - "doctor" to doctorResult, - ).forEach { (name, result) -> - if (result is NetworkResult.Failure) { - Log.w(TAG, "$name endpoint: ${result.error.message}") - } + if (statsResult is NetworkResult.Failure) { + Log.w(TAG, "stats endpoint: ${statsResult.error.message}") + } + if (statusResult is NetworkResult.Failure) { + Log.w(TAG, "status endpoint: ${statusResult.error.message}") + } + } + + // Phase 2: Progressively hydrate remaining sections as each endpoint resolves + launch { + val portalResult = portalDeferred.await() + if (portalResult is NetworkResult.Success) { + _uiState.update { it.copy(portal = portalResult.data) } + } else if (BuildConfig.DEBUG && portalResult is NetworkResult.Failure) { + Log.w(TAG, "portal endpoint: ${portalResult.error.message}") + } + } + + launch { + val curatorResult = curatorDeferred.await() + if (curatorResult is NetworkResult.Success) { + _uiState.update { it.copy(curator = curatorResult.data) } + } else if (BuildConfig.DEBUG && curatorResult is NetworkResult.Failure) { + Log.w(TAG, "curator endpoint: ${curatorResult.error.message}") + } + } + + launch { + val credResult = credDeferred.await() + if (credResult is NetworkResult.Success) { + _uiState.update { it.copy(credentials = credResult.data.providers.orEmpty()) } + } else if (BuildConfig.DEBUG && credResult is NetworkResult.Failure) { + Log.w(TAG, "credentials endpoint: ${credResult.error.message}") + } + } + + launch { + val checkpointsResult = checkpointsDeferred.await() + if (checkpointsResult is NetworkResult.Success) { + _uiState.update { it.copy(checkpoints = checkpointsResult.data) } + } else if (BuildConfig.DEBUG && checkpointsResult is NetworkResult.Failure) { + Log.w(TAG, "checkpoints endpoint: ${checkpointsResult.error.message}") + } + } + + launch { + val hooksResult = hooksDeferred.await() + if (hooksResult is NetworkResult.Success) { + _uiState.update { it.copy(hooks = hooksResult.data) } + } else if (BuildConfig.DEBUG && hooksResult is NetworkResult.Failure) { + Log.w(TAG, "hooks endpoint: ${hooksResult.error.message}") + } + } + + launch { + val updateResult = updateDeferred.await() + if (updateResult is NetworkResult.Success) { + _uiState.update { it.copy(updateInfo = updateResult.data) } + } else if (BuildConfig.DEBUG && updateResult is NetworkResult.Failure) { + Log.w(TAG, "update endpoint: ${updateResult.error.message}") + } + } + + launch { + val doctorResult = doctorDeferred.await() + if (doctorResult is NetworkResult.Success) { + _uiState.update { it.copy(doctorReport = doctorResult.data) } + } else if (BuildConfig.DEBUG && doctorResult is NetworkResult.Failure) { + Log.w(TAG, "doctor endpoint: ${doctorResult.error.message}") } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index 3f938d9e..9378ed04 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -42,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.ToolsetDetailKey +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -60,9 +61,15 @@ fun ToolsetsScreen( viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() + viewModel.loadToolsets() + } + val filteredToolsets = remember(query, state.toolsets) { state.toolsets.filter { toolset -> @@ -72,17 +79,13 @@ fun ToolsetsScreen( } } - LaunchedEffect(Unit) { - viewModel.loadToolsets() - } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) HermesScaffold( title = { Text(stringResource(R.string.screen_toolsets)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadToolsets() }, + onRefresh = { viewModel.loadToolsets(forceRefresh = true) }, ) { paddingValues -> when { state.isLoading && state.toolsets.isEmpty() -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt index 03fed24f..be72c4c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsViewModel.kt @@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.toolsets 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.Toolset import com.m57.hermescontrol.data.model.ToolsetToggleRequest import com.m57.hermescontrol.data.remote.ApiClient @@ -9,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 @@ -30,8 +34,19 @@ class ToolsetsViewModel : private val _uiState = MutableStateFlow(ToolsetsUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadToolsets() { - safeLaunchLoad( + private val toolsetsCache = SwrCache>() + + fun clearScopeOwnedState() { + _uiState.update { it.copy(isLoading = false, toolsets = emptyList(), errorMessage = null) } + } + + fun loadToolsets(forceRefresh: Boolean = false) { + safeLaunchSwrLoad( + cache = toolsetsCache, + forceRefresh = forceRefresh, + onCacheHit = { cached -> + _uiState.update { it.copy(isLoading = false, toolsets = cached, errorMessage = null) } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getToolsets() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> @@ -51,6 +66,7 @@ class ToolsetsViewModel : fun toggleToolset(toolset: Toolset) { val originalEnabled = toolset.enabled val targetEnabled = !originalEnabled + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() // Optimistic UI update _uiState.update { state -> @@ -67,8 +83,19 @@ class ToolsetsViewModel : withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled)) } } - if (result is NetworkResult.Failure) { + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + if (requestScope != null && currentScope != requestScope) return@launch + if (result is NetworkResult.Success) { + val updated = _uiState.value.toolsets + if (requestScope != null) { + toolsetsCache.put(requestScope.scopedKey("default"), updated) + } + } else if (result is NetworkResult.Failure) { revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${result.error.message}") + val reverted = _uiState.value.toolsets + if (requestScope != null) { + toolsetsCache.put(requestScope.scopedKey("default"), reverted) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index 6fb6ddc3..0684e48f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -48,6 +48,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.WebhookSubscription import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState @@ -71,6 +72,7 @@ fun WebhooksScreen( viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } var showDetail by remember { mutableStateOf(null) } @@ -84,7 +86,8 @@ fun WebhooksScreen( } } - LaunchedEffect(Unit) { + LaunchedEffect(dataScope) { + viewModel.clearScopeOwnedState() viewModel.loadWebhooks() } @@ -232,7 +235,7 @@ fun WebhooksScreen( title = { Text(stringResource(R.string.screen_webhooks)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadWebhooks() }, + onRefresh = { viewModel.loadWebhooks(forceRefresh = true) }, actions = { IconButton(onClick = { viewModel.showCreateDialog() }) { Icon( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt index bf39c48b..3ef4a280 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksViewModel.kt @@ -2,15 +2,18 @@ package com.m57.hermescontrol.ui.webhooks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.WebhookSubscription import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest +import com.m57.hermescontrol.data.model.WebhooksResponse import com.m57.hermescontrol.data.model.WebhooksToggleRequest 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 @@ -48,8 +51,37 @@ class WebhooksViewModel : private val _uiState = MutableStateFlow(WebhooksUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadWebhooks() { - safeLaunchLoad( + private val webhooksCache = SwrCache() + + fun clearScopeOwnedState() { + _uiState.update { + it.copy( + isLoading = false, + enabled = false, + baseUrl = null, + subscriptions = emptyList(), + errorMessage = null, + deleteTarget = null, + togglingName = null, + ) + } + } + + fun loadWebhooks(forceRefresh: Boolean = false) { + safeLaunchSwrLoad( + cache = webhooksCache, + forceRefresh = forceRefresh, + onCacheHit = { data -> + _uiState.update { + it.copy( + isLoading = false, + enabled = data.enabled, + baseUrl = data.base_url, + subscriptions = data.subscriptions.orEmpty(), + errorMessage = null, + ) + } + }, apiCall = { safeApiCall { ApiClient.hermesApi.getWebhooks() } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> @@ -90,7 +122,7 @@ class WebhooksViewModel : toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}", ) } - loadWebhooks() + loadWebhooks(forceRefresh = true) } is NetworkResult.Failure -> { @@ -185,7 +217,7 @@ class WebhooksViewModel : toastMessage = "Subscription \"$name\" created", ) } - loadWebhooks() + loadWebhooks(forceRefresh = true) } is NetworkResult.Failure -> { @@ -226,7 +258,7 @@ class WebhooksViewModel : toastMessage = "Subscription \"$name\" ${if (enabled) "enabled" else "disabled"}", ) } - loadWebhooks() + loadWebhooks(forceRefresh = true) } is NetworkResult.Failure -> { @@ -263,7 +295,7 @@ class WebhooksViewModel : _uiState.update { it.copy(toastMessage = "Subscription \"${target.name}\" deleted") } - loadWebhooks() + loadWebhooks(forceRefresh = true) } is NetworkResult.Failure -> { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 979a86cf..6c524f74 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -128,6 +128,10 @@ الملفات Hermes بانتظار ردود Hermes +متصل في الخلفية +جارٍ الاتصال… +في انتظار الشبكة +جارٍ إعادة الاتصال رسالة جديدة يحتاج Hermes إلى توضيح اكتب ردك\u2026 @@ -164,9 +168,12 @@ أداة عرض ملف JSON الأولي عرض المخرجات المعالجة +تنسيق JSON +ضغط JSON محادثة جديدة التمرير إلى الأسفل نسخ +تم النسخ تعديل %1$s رمز %1$s رمز/ث @@ -174,6 +181,8 @@ تعذر التقاط الصورة الاتصال رمز المصادقة (Token) +البقاء متصلاً في الخلفية +إبقاء هيرمس متصلاً أثناء استخدام تطبيقات أخرى. يعرض إشعاراً مستمراً وقد يستهلك المزيد من البطارية. السلوك إعادة الاتصال التلقائي إعادة الاتصال تلقائيًا عند انقطاعه @@ -189,6 +198,8 @@ رموز رسالة المستخدم رموز رسالة المساعد الرموز في الثانية (TPS) + إظهار اسم المزود + إظهار المزود قبل اسم النموذج في شريط كتابة المحادثة. الوكيل يكتب يفكّر… يبحث… @@ -233,6 +244,8 @@ السماح بتثبيت التطبيقات من هذا المصدر فتح الإعدادات إعادة المحاولة + التحقق من الإصدارات المرشّحة (RC) + تثبيت إصدارات ما قبل الإطلاق. قد تكون أقل استقرارًا. تم حفظ %1$d تأثير الكتابة مفعّل تأثير الكتابة معطّل @@ -1189,6 +1202,7 @@ Hermes Mobile لوحة التحكم لـ Hermes Agent تسجيل الدخول والمصادقة +فشل في تهيئة إعدادات التطبيق لا يوجد موفرو OAuth سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق. اتصال @@ -1474,4 +1488,26 @@ إعادة المحاولة إغلاق تكاملات الجلسة + + + تشغيل + إيقاف مؤقت + إعادة التشغيل + إعادة المحاولة + صوت + فيديو + إغلاق + حفظ في الجهاز + مشاركة + مشاركة الوسائط + تم حفظ الوسائط + تعذر حفظ الوسائط + تعذر تحميل الوسائط: %1$s + تعذر مشاركة الوسائط + جار التحميل… + تقديم/ترجيع + تشغيل الصوت + تشغيل الفيديو + جارٍ تجهيز الجلسة… + الجلسة غير جاهزة — أعد المحاولة للمتابعة \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6e41aff4..8d9afa91 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -125,6 +125,10 @@ 헤르메스 헤르메스 응답 대기 중 + 백그라운드에서 연결됨 + 연결 중… + 네트워크 대기 중 + 재연결 중 새 메시지 헤르메스의 확인이 필요합니다 답변 입력… @@ -161,9 +165,12 @@ 도구 Raw JSON 보기 파싱된 결과 보기 + JSON 포맷팅 + JSON 압축 새 채팅 맨 아래로 복사 + 복사됨 편집 %1$s 토큰 %1$s t/s @@ -177,6 +184,8 @@ 연결 인증 토큰 + 백그라운드에서 연결 유지 + 다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다. 동작 자동 재연결 연결 끊김 시 자동 재연결 @@ -192,6 +201,8 @@ 사용자 메시지 토큰 어시스턴트 메시지 토큰 초당 토큰 수 (TPS) + 제공자 이름 표시 + 채팅 작성창에서 모델 이름 앞에 제공자를 표시합니다. 에이전트 입력 중 생각 중… 검색 중… @@ -235,6 +246,8 @@ 이 소스의 설치 허용 설정 열기 다시 시도 + 릴리스 후보 업데이트 확인 + 사전 출시(RC) 빌드를 설치합니다. 안정성이 낮을 수 있습니다. 저장 프로필 추가 프로필 편집 @@ -1008,7 +1021,8 @@ Hermes Agent 제어판 인증 로그인 - + + 앱 설정을 초기화하지 못했습니다 OAuth 제공자가 없습니다 @@ -1158,4 +1172,26 @@ 다시 시도 닫기 세션 연동 + + + 재생 + 일시중지 + 다시 재생 + 다시 시도 + 오디오 + 비디오 + 닫기 + 기기에 저장 + 공유 + 미디어 공유 + 미디어가 저장되었습니다 + 미디어를 저장할 수 없습니다 + 미디어를 불러올 수 없습니다: %1$s + 미디어를 공유할 수 없습니다 + 로딩 중… + 탐색 + 오디오 재생 + 비디오 재생 + 세션 준비 중… + 세션이 준비되지 않았습니다. 다시 시도하세요 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8f537e16..7fcf777c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -128,6 +128,10 @@ 文件 Hermes 等待 Hermes 回复 + 在后台保持连接 + 正在连接… + 等待网络连接 + 正在重新连接 新消息 Hermes 需要确认 输入回复… @@ -164,9 +168,12 @@ 工具 显示原始 JSON 显示解析输出 + 格式化 JSON + 压缩 JSON 新建对话 滚动到底部 复制 + 已复制 编辑 %1$s tok %1$s t/s @@ -174,6 +181,8 @@ 拍照失败 连接 认证令牌 + 在后台保持连接 + 在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。 行为 自动重连 断开后自动重新连接 @@ -189,6 +198,8 @@ 用户消息令牌 助手消息令牌 每秒令牌数 (TPS) + 显示提供商名称 + 在聊天输入栏的模型名称前显示提供商。 助手正在输入 正在思考… 正在搜索… @@ -233,6 +244,8 @@ 允许安装来自此来源的应用 打开设置 重试 + 检查候选版本更新 + 安装预发布候选版本 (RC)。稳定性可能较低。 已保存 %1$d 个 打字效果已开启 打字效果已关闭 @@ -1156,6 +1169,7 @@ Hermes Mobile Hermes Agent 控制面板 认证登录 + 应用设置初始化失败 没有 OAuth 提供商 已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。 连接 @@ -1427,4 +1441,26 @@ 重试 关闭 会话集成 + + + 播放 + 暂停 + 重播 + 重试 + 音频 + 视频 + 关闭 + 保存到设备 + 分享 + 分享媒体 + 媒体已保存 + 无法保存媒体 + 无法加载媒体: %1$s + 无法分享媒体 + 正在加载… + 跳转进度 + 播放音频 + 播放视频 + 正在准备会话… + 会话未就绪,请重试以继续 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2059463d..a7da130e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,10 @@ Hermes Waiting for Hermes replies + Connected in background + Connecting… + Waiting for network + Reconnecting New message Hermes needs a clarification Hermes needs your input @@ -182,9 +186,12 @@ Tool Show Raw JSON Show Parsed Output + Format JSON + Compact JSON New Chat Scroll to bottom Copy + Copied Edit %1$s tok %1$s t/s @@ -198,6 +205,8 @@ Connection Auth Token + Stay connected in background + Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. Behavior Auto-Reconnect Automatically reconnect on disconnect @@ -213,6 +222,8 @@ User message tokens Assistant message tokens Tokens per second (TPS) + Show provider name + Show the provider before the model name in the chat composer. Agent is typing Thinking… Searching… @@ -254,6 +265,8 @@ Allow installs from this source Open settings Retry + Check release candidate updates + Install pre-release RC builds. They may be less stable. Support Hermes Mobile If Hermes Mobile saves you time, you can support its development. Support me on Ko-fi @@ -1303,7 +1316,8 @@ Control panel for Hermes Agent Auth Login - + + Failed to initialize app settings No OAuth providers @@ -1641,4 +1655,26 @@ Retry Close Session Integrations + + + Play + Pause + Replay + Retry + Audio + Video + Close + Save to device + Share + Share media + Media saved + Could not save media + Could not load media: %1$s + Could not share media + Loading… + Seek + Play audio + Play video + Preparing session… + Session not ready — retry to continue diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 20d62d60..961ae46f 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -50,6 +50,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.ws.KanbanEventsClient +import com.m57.hermescontrol.data.ws.ModelOptionsRepository import com.m57.hermescontrol.ui.channels.ChannelsViewModel import com.m57.hermescontrol.ui.connect.ConnectViewModel import com.m57.hermescontrol.ui.cron.CronJobsViewModel @@ -128,6 +129,20 @@ class E2eIntegrationTest { @After fun tearDown() { + // Restore the GLOBAL Dispatchers object BEFORE resetMain. + // + // setUp() stubs `Dispatchers.Main` to return the TestMainDispatcher captured + // at that moment. unmockkAll() alone -- and it ran AFTER resetMain -- left + // that stub live, so Dispatchers.Main kept handing back a TestMainDispatcher + // whose delegate had already been reset, and it THROWS on dispatch. Any + // later class in this JVM that resumes a continuation onto Main then died + // with DispatchException. That is precisely what broke + // ProfileSwitchCoordinatorTest (its _switched.tryEmit resumes collectors on + // Main) with 2x DispatchException + 2x "test body did not run to completion". + // + // Verified by repro: E2eIntegrationTest + ProfileSwitchCoordinatorTest + // together = 4 failures; ProfileSwitchCoordinatorTest alone = 5/5 pass. + unmockkStatic(Dispatchers::class) Dispatchers.resetMain() unmockkAll() NavigationController.backStack = null @@ -1293,7 +1308,8 @@ class E2eIntegrationTest { ), ) - val viewModel = ModelViewModel() + // Issue #1164: keep the real WS singleton off this test's mocked IO dispatcher. + val viewModel = ModelViewModel(ModelOptionsRepository(connected = { false })) viewModel.loadAll() advanceUntilIdle() @@ -1352,7 +1368,8 @@ class E2eIntegrationTest { } // Default loadAll() must call getModelOptions with refresh = false - val viewModel = ModelViewModel() + // Issue #1164: keep the real WS singleton off this test's mocked IO dispatcher. + val viewModel = ModelViewModel(ModelOptionsRepository(connected = { false })) viewModel.loadAll() advanceUntilIdle() diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt index 2996d277..801b6dc8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.config import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -16,6 +17,8 @@ class ServerStoreTest { assertTrue(state.autoReconnect) assertEquals("token", state.wsAuthParam) assertEquals(1.0f, state.chatFontScale) + assertFalse(state.showModelProvider) + assertFalse(state.keepConnectedInBackground) assertTrue(state.connectionProfiles.isEmpty()) assertNull(state.selectedProfileId) } @@ -67,4 +70,41 @@ class ServerStoreTest { assertEquals("127.0.0.1", unselectedState.resolvedHost) assertEquals(9119, unselectedState.resolvedPort) } + + @Test + fun testShowModelProviderUpdate() { + val state = ServerStoreState() + assertFalse(state.showModelProvider) + val updated = state.copy(showModelProvider = true) + assertTrue(updated.showModelProvider) + } + + @Test + fun testKeepConnectedInBackgroundUpdate() { + val state = ServerStoreState() + assertFalse(state.keepConnectedInBackground) + val updated = state.copy(keepConnectedInBackground = true) + assertTrue(updated.keepConnectedInBackground) + } + + @Test + fun testKeepConnectedInBackground_deserializationDefault() { + val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + val decoded = json.decodeFromString("{}") + assertFalse(decoded.keepConnectedInBackground) + } + + @Test + fun testCheckReleaseCandidateUpdatesDefaultsToStableOnly() { + val state = ServerStoreState() + assertFalse(state.checkReleaseCandidateUpdates) + assertTrue(state.copy(checkReleaseCandidateUpdates = true).checkReleaseCandidateUpdates) + } + + @Test + fun testCheckReleaseCandidateUpdates_deserializationDefault() { + val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + val decoded = json.decodeFromString("{}") + assertFalse(decoded.checkReleaseCandidateUpdates) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 1c29fed8..05c58e06 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -10,6 +10,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.async import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -84,16 +85,13 @@ class AuthManagerTest { storeField.isAccessible = true storeField.set(AuthManager, null) - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } // Initialise AuthManager AuthManager.init(testContext) // Wait for async initialization to complete to prevent coroutine leaks - kotlinx.coroutines.runBlocking { - val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*> - deferred?.await() - } + kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() } // Wait for ServerStore initialization to complete AuthManager.serverStore.getLatestState() @@ -105,7 +103,7 @@ class AuthManagerTest { // into later classes — leaked collectors re-touch deleted/recreated // server_store.json and surface as UncaughtExceptionsBeforeTest // phantoms in whichever test class runs next. - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempFile = java.io.File(tempDir, "server_store.json") if (tempFile.exists()) { @@ -114,6 +112,99 @@ class AuthManagerTest { unmockkAll() } + @Test + fun delayedKeystoreDoesNotBlockCallerOrExpireAfterTwoSeconds() = + kotlinx.coroutines.runBlocking { + AuthManager.resetAndAwaitForTest() + val entered = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + every { MasterKeys.getOrCreate(any()) } answers { + entered.countDown() + check(release.await(10, java.util.concurrent.TimeUnit.SECONDS)) + "mockMasterKey" + } + AuthManager.init(testContext) + try { + org.junit.Assert.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)) + assertEquals(AuthManager.InitializationState.Loading, AuthManager.initializationState.value) + val waiter = async { AuthManager.awaitInitialization() } + kotlinx.coroutines.delay(2100) + org.junit.Assert.assertFalse(waiter.isCompleted) + release.countDown() + waiter.await() + assertEquals(AuthManager.InitializationState.Ready, AuthManager.initializationState.value) + } finally { + release.countDown() + } + } + + @Test + fun failedKeystoreCanRetryWithoutClearingCredentials() = + kotlinx.coroutines.runBlocking { + AuthManager.resetAndAwaitForTest() + every { MasterKeys.getOrCreate(any()) } throws IllegalStateException("keystore unavailable") + AuthManager.init(testContext) + val failure = runCatching { AuthManager.awaitInitialization() }.exceptionOrNull() + org.junit.Assert.assertTrue(failure is IllegalStateException) + assertEquals(AuthManager.InitializationState.Failed, AuthManager.initializationState.value) + every { MasterKeys.getOrCreate(any()) } returns "mockMasterKey" + every { mockPrefs.getString("token_default", null) } returns "restored-token" + AuthManager.init(testContext) + AuthManager.awaitInitialization() + assertEquals("restored-token", AuthManager.getToken()) + assertEquals(AuthManager.InitializationState.Ready, AuthManager.initializationState.value) + } + + @Test + fun databaseKeyIsCreatedOnceAndCacheReturnsDefensiveCopies() = + kotlinx.coroutines.runBlocking { + every { mockPrefs.getString("db_password", null) } returns null + every { mockEditor.commit() } returns true + val first = AuthManager.getDatabasePassword() + val original = first.copyOf() + first.fill(0) + org.junit.Assert.assertArrayEquals(original, AuthManager.getDatabasePassword()) + assertEquals(32, original.size) + verify(exactly = 1) { mockPrefs.getString("db_password", null) } + verify(exactly = 1) { mockEditor.commit() } + } + + @Test + fun existingDatabaseKeyIsPreserved() = + kotlinx.coroutines.runBlocking { + val original = ByteArray(32) { it.toByte() } + every { mockPrefs.getString("db_password", null) } returns + java.util.Base64 + .getEncoder() + .encodeToString(original) + org.junit.Assert.assertArrayEquals(original, AuthManager.getDatabasePassword()) + verify(exactly = 0) { mockEditor.commit() } + } + + @Test + fun failedDatabaseKeyWriteDoesNotCacheUnsavedPassword() = + kotlinx.coroutines.runBlocking { + every { mockPrefs.getString("db_password", null) } returns null + every { mockEditor.commit() } returns false + org.junit.Assert.assertTrue(runCatching { AuthManager.getDatabasePassword() }.isFailure) + every { mockEditor.commit() } returns true + assertEquals(32, AuthManager.getDatabasePassword().size) + verify(exactly = 2) { mockEditor.commit() } + } + + @Test + fun defaultTokenUpdateRefreshesInheritedTokenCache() { + AuthManager.saveConnectionProfiles(listOf(ConnectionProfile(id = "other", name = "Other"))) + every { mockPrefs.getString("token_other", null) } returns null + every { mockPrefs.getString("token_default", null) } returns "old" + AuthManager.setSelectedProfileId("other") + assertEquals("old", AuthManager.getToken()) + every { mockPrefs.getString("token_default", null) } returns "new" + AuthManager.setProfileToken("default", "new") + assertEquals("new", AuthManager.getToken()) + assertEquals("new", AuthManager.tokenFlow.value) + } + @Test fun testGetAndSetToken() { AuthManager.ensureDefaultProfile() @@ -491,13 +582,9 @@ class AuthManagerTest { every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false // Re-initialize so init() runs the one-time migration - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } AuthManager.init(testContext) - kotlinx.coroutines.runBlocking { - val field = AuthManager::class.java.getDeclaredField("prefsDeferred") - field.isAccessible = true - (field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await() - } + kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() } // token should now be persisted under the default profile key, and the legacy key removed verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "legacy-standalone-token") } @@ -542,13 +629,9 @@ class AuthManagerTest { fun testActiveProfileId_restoredFromPrefsAfterRestart() { every { mockPrefs.getString("active_profile_id", null) } returns "meow" - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } AuthManager.init(testContext) - kotlinx.coroutines.runBlocking { - val field = AuthManager::class.java.getDeclaredField("prefsDeferred") - field.isAccessible = true - (field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await() - } + kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() } assertEquals("meow", AuthManager.activeProfileId.value) @@ -557,7 +640,7 @@ class AuthManagerTest { // prefs/DataStore state and break subsequent mockkObject(AuthManager) // classes — the "Missing mocked calls" / UncaughtExceptionsBeforeTest // suite-order failures). - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt new file mode 100644 index 00000000..222c3ce2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt @@ -0,0 +1,371 @@ +package com.m57.hermescontrol.data.local + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Behavioural proof that PR #1192's caches are partitioned by [DataScope]. + * + * These tests exercise the cache invariants end-to-end through [safeLaunchSwrLoad] + * rather than only asserting key derivation, so a regression that keeps keys + * correct but reuses the wrong entry still fails here. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CacheScopeIsolationTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private class TestViewModel : ViewModel() + + /** + * Every ViewModel created by a test. `viewModelScope` jobs are never cancelled + * automatically, so a still-pending dispatch would hit `Dispatchers.Main` *after* + * `resetMain()` and surface as an `UncaughtExceptionsBeforeTest` in a later test. + * Cancel them before tearing the main dispatcher down. + */ + private val createdViewModels = mutableListOf() + + private fun newViewModel(): TestViewModel = TestViewModel().also { createdViewModels += it } + + /** Connection A — server 192.168.1.57, Hermes profile "main". */ + private val scopeA = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L) + + /** Connection B — a different Hermes *server* entirely. */ + private val scopeB = DataScope("conn-b", "http://10.0.0.9:9119", "main", 0L) + + /** Same server as [scopeA], different server-side Hermes profile. */ + private val scopeAOtherProfile = DataScope("conn-a", "http://192.168.1.57:9119", "work", 0L) + + /** Same connection+profile as [scopeA], but a new authenticated session (logout/login). */ + private val scopeANewSession = scopeA.copy(inMemoryAuthGeneration = 1L) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() + testDispatcher.scheduler.advanceUntilIdle() + Dispatchers.resetMain() + } + + // ── Cross-context isolation ────────────────────────────────────────────── + + @Test + fun testCacheWrittenInOneScopeIsInvisibleInAnother() { + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("profiles"), "server-A-profiles") + + assertNull("connection B must not see connection A's cache", cache.get(scopeB.inMemoryKey("profiles"))) + assertNull( + "another Hermes profile on the same server must not see A's cache", + cache.get(scopeAOtherProfile.inMemoryKey("profiles")), + ) + assertEquals("server-A-profiles", cache.get(scopeA.inMemoryKey("profiles"))) + } + + @Test + fun testScopeSwitchDoesNotRenderPreviousContextData() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("default"), "stale-server-A-data") + + // Screen is now rendering scope B and loads with scope B's request scope. + var renderedFromCache: String? = null + var renderedFromNetwork: String? = null + + vm.safeLaunchSwrLoad( + cache = cache, + requestScope = scopeB, + scopeIsCurrent = { true }, + onCacheHit = { renderedFromCache = it }, + apiCall = { NetworkResult.Success("server-B-data") }, + onStart = {}, + onSuccess = { renderedFromNetwork = it }, + onError = {}, + ) + + assertNull("scope B must never be served scope A's cached payload", renderedFromCache) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("server-B-data", renderedFromNetwork) + assertEquals("server-B-data", cache.get(scopeB.inMemoryKey("default"))) + assertEquals( + "server A's entry is untouched", + "stale-server-A-data", + cache.get(scopeA.inMemoryKey("default")), + ) + } + + @Test + fun testLogoutReloginCannotReuseAnotherSessionsInMemoryCache() { + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("sessions"), "session-1-confidential-titles") + + assertNull( + "a new authenticated session must not reuse the previous session's in-memory data", + cache.get(scopeANewSession.inMemoryKey("sessions")), + ) + } + + @Test + fun testPersistentKeySurvivesProcessRestartButNotServerChange() { + // Same physical server + profile, fresh process (generation resets to 0). + val afterRestart = scopeA.copy(inMemoryAuthGeneration = 0L) + + assertEquals( + "disk cache must survive a normal process restart for cold-start speed", + scopeA.persistentKey("sessions"), + afterRestart.persistentKey("sessions"), + ) + assertNotEquals( + "disk cache must never bridge two different servers", + scopeA.persistentKey("sessions"), + scopeB.persistentKey("sessions"), + ) + } + + // ── Same-context SWR stays instant ─────────────────────────────────────── + + @Test + fun testSameScopeRevisitRendersCacheImmediately() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("default"), "cached-for-A") + + var cacheHit: String? = null + vm.safeLaunchSwrLoad( + cache = cache, + requestScope = scopeA, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Success("revalidated-for-A") }, + onStart = {}, + onSuccess = {}, + onError = {}, + ) + + assertEquals("revisiting the same context must paint from cache", "cached-for-A", cacheHit) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("revalidated-for-A", cache.get(scopeA.inMemoryKey("default"))) + } + + // ── Manual refresh ─────────────────────────────────────────────────────── + + @Test + fun testForceRefreshPurgesOnlyItsOwnScope() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("default"), "A-data") + cache.put(scopeB.inMemoryKey("default"), "B-data") + + var cacheHit: String? = null + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = true, + requestScope = scopeB, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Success("B-fresh") }, + onStart = {}, + onSuccess = {}, + onError = {}, + ) + + assertNull("force refresh must not replay the cached payload", cacheHit) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("B-fresh", cache.get(scopeB.inMemoryKey("default"))) + assertEquals("other scopes' entries must survive", "A-data", cache.get(scopeA.inMemoryKey("default"))) + } + + // ── Mutation reconciliation ────────────────────────────────────────────── + + @Test + fun testSuccessfulMutationCannotReplayPreMutationCache() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + val key = scopeA.inMemoryKey("webhooks") + + // Pre-mutation server state. + cache.put(key, "webhook-disabled") + + var cacheHit: String? = null + var success: String? = null + + // Post-mutation reload: must bypass the now-stale cache entry. + vm.safeLaunchSwrLoad( + cache = cache, + cacheKey = "webhooks", + forceRefresh = true, + requestScope = scopeA, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Success("webhook-enabled") }, + onStart = {}, + onSuccess = { success = it }, + onError = {}, + ) + + assertNull("pre-mutation state must not be replayed", cacheHit) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("webhook-enabled", success) + assertEquals("webhook-enabled", cache.get(key)) + } + + @Test + fun testMutationReloadFailureAfterForcedRefreshSurfacesError() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + cache.put(scopeA.inMemoryKey("default"), "pre-mutation") + + var error: String? = null + var cacheHit: String? = null + + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = true, + requestScope = scopeA, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Failure(NetworkError.Http(500, "boom")) }, + onStart = {}, + onSuccess = {}, + onError = { error = it }, + ) + + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(cacheHit) + assertNotNull( + "a failed post-mutation reload must not silently show pre-mutation state", + error, + ) + } + + // ── Stale in-flight results ────────────────────────────────────────────── + + @Test + fun testLateResponseFromPreviousScopeCannotOverwriteNewState() = + testScope.runTest { + val vm = newViewModel() + val cache = SwrCache() + + var cacheHit: String? = null + var success: String? = null + var error: String? = null + + // Request dispatched while scope A was active; the scope flips to B before it returns. + var scopeIsCurrent = true + val job = + vm.safeLaunchSwrLoad( + cache = cache, + requestScope = scopeA, + scopeIsCurrent = { scopeIsCurrent }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Success("server-A-data") }, + onStart = {}, + onSuccess = { success = it }, + onError = { error = it }, + ) + + scopeIsCurrent = false + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(job.isCompleted) + assertNull("stale scope A response must not be rendered", success) + assertNull( + "stale scope A response must not enter scope A's cache", + cache.get(scopeA.inMemoryKey("default")), + ) + assertNull(cacheHit) + assertNull(error) + } + + // ── Config editor isolation ────────────────────────────────────────────── + + @Test + fun testConfigSchemaAndDefaultsNeverCrossScopeBoundaries() { + val schemaCache = SwrCache(maxCapacity = 10) + val defaultsCache = SwrCache(maxCapacity = 10) + + val keyA = scopeA.inMemoryKey("schema") + val keyB = scopeB.inMemoryKey("schema") + + schemaCache.put(keyA, "schema-A") + defaultsCache.put(keyA, "defaults-A") + + assertNull("new context must not inherit another server's schema", schemaCache.get(keyB)) + assertNull("new context must not inherit another server's defaults", defaultsCache.get(keyB)) + + schemaCache.put(keyB, "schema-B") + defaultsCache.put(keyB, "defaults-B") + + assertEquals("schema-A", schemaCache.get(keyA)) + assertEquals("schema-B", schemaCache.get(keyB)) + assertEquals("defaults-A", defaultsCache.get(keyA)) + assertEquals("defaults-B", defaultsCache.get(keyB)) + } + + @Test + fun testConfigForceRefreshDoesNotEvictOtherScopes() { + val schemaCache = SwrCache(maxCapacity = 10) + val keyA = scopeA.inMemoryKey("schema") + val keyB = scopeB.inMemoryKey("schema") + schemaCache.put(keyA, "schema-A") + schemaCache.put(keyB, "schema-B") + + // Manual refresh while scoped to B. + schemaCache.remove(keyB) + + assertNull(schemaCache.get(keyB)) + assertEquals( + "editing config on one server must not wipe another server's cache", + "schema-A", + schemaCache.get(keyA), + ) + } + + // ── History pagination keys ────────────────────────────────────────────── + + @Test + fun testHistoryPaginationKeysAreScopedPerSectionAndScope() { + val cache = SwrCache() + val sectionKey = "recent:cli:false" + + cache.put(scopeA.inMemoryKey(sectionKey), "page-1-for-A") + + assertNull(cache.get(scopeB.inMemoryKey(sectionKey))) + assertNull(cache.get(scopeAOtherProfile.inMemoryKey(sectionKey))) + assertEquals("page-1-for-A", cache.get(scopeA.inMemoryKey(sectionKey))) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt new file mode 100644 index 00000000..89ee1158 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt @@ -0,0 +1,79 @@ +package com.m57.hermescontrol.data.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DataScopeTest { + @Test + fun testSameScopeProducesSameKeys() { + val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L) + val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 0L) + + assertEquals(scope1, scope2) + assertEquals(scope1.inMemoryKey("sessions"), scope2.inMemoryKey("sessions")) + assertEquals(scope1.persistentKey("sessions"), scope2.persistentKey("sessions")) + } + + @Test + fun testDifferentConnectionProducesDifferentKeys() { + val scope1 = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L) + val scope2 = DataScope("conn-b", "http://192.168.1.57:9119", "main", 0L) + + assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey()) + assertNotEquals(scope1.persistentKey(), scope2.persistentKey()) + } + + @Test + fun testChangedBaseUrlProducesDifferentKeys() { + val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L) + val scope2 = DataScope("default", "http://10.0.0.2:9119", "main", 0L) + + assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey()) + assertNotEquals(scope1.persistentKey(), scope2.persistentKey()) + } + + @Test + fun testDifferentActiveProfileProducesDifferentKeys() { + val scope1 = DataScope("default", "http://192.168.1.57:9119", "profile-1", 0L) + val scope2 = DataScope("default", "http://192.168.1.57:9119", "profile-2", 0L) + + assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey()) + assertNotEquals(scope1.persistentKey(), scope2.persistentKey()) + } + + @Test + fun testAuthGenerationInvalidatesInMemoryKeyOnly() { + val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L) + val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 1L) + + assertNotEquals(scope1.inMemoryKey("default"), scope2.inMemoryKey("default")) + assertEquals(scope1.persistentKey("default"), scope2.persistentKey("default")) + } + + @Test + fun testLengthPrefixPreventsDelimiterInjection() { + val scopeA = DataScope("c:1", "http://url", "p", 0L) + val scopeB = DataScope("c", "1:http://url", "p", 0L) + + assertNotEquals(scopeA.persistentKey("k"), scopeB.persistentKey("k")) + assertNotEquals(scopeA.inMemoryKey("k"), scopeB.inMemoryKey("k")) + } + + @Test + fun testNoSecretsInKeys() { + val scope = DataScope("profile-id", "http://host:9119", "hermes-profile", 0L) + val inMemory = scope.inMemoryKey("local") + val persistent = scope.persistentKey("local") + + assertTrue(!inMemory.contains("token", ignoreCase = true)) + assertTrue(!inMemory.contains("password", ignoreCase = true)) + assertTrue(!inMemory.contains("secret", ignoreCase = true)) + assertTrue(!persistent.contains("token", ignoreCase = true)) + assertTrue(!persistent.contains("password", ignoreCase = true)) + assertTrue(!persistent.contains("secret", ignoreCase = true)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt index 63a290fa..f57f20ac 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt @@ -85,4 +85,16 @@ class HermesDatabaseMigrationsTest { ) } } + + @Test + fun migration7to8_executesCompletionIdColumnAdditionOnSQLiteConnection() = + runBlocking { + val connection = mockk(relaxed = true) + HermesDatabase.MIGRATION_7_8.migrate(connection) + verify(exactly = 1) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT", + ) + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index 8a8869b6..cf8fb9bc 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -90,13 +90,10 @@ class Issue647ProfileUrlTest { storeField.isAccessible = true storeField.set(AuthManager, null) - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } AuthManager.init(testContext) - kotlinx.coroutines.runBlocking { - val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*> - deferred?.await() - } + kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() } AuthManager.serverStore.getLatestState() } @@ -106,7 +103,7 @@ class Issue647ProfileUrlTest { // into later classes — leaked collectors re-touch deleted/recreated // server_store.json and surface as UncaughtExceptionsBeforeTest // phantoms in whichever test class runs next. - AuthManager.resetAuthStateForTest() + kotlinx.coroutines.runBlocking { AuthManager.resetAndAwaitForTest() } val tempDir = java.io.File(System.getProperty("java.io.tmpdir") ?: "/tmp") val tempFile = java.io.File(tempDir, "server_store.json") if (tempFile.exists()) tempFile.delete() diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt new file mode 100644 index 00000000..cf41a7dc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.data.local + +import android.content.Context +import android.content.SharedPreferences +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionListResponse +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class SessionListCacheStoreTest { + private lateinit var mockPrefs: SharedPreferences + private lateinit var mockEditor: SharedPreferences.Editor + private lateinit var mockContext: Context + private val prefStorage = mutableMapOf() + + @Before + fun setUp() { + prefStorage.clear() + mockPrefs = mockk(relaxed = true) + mockEditor = mockk(relaxed = true) + mockContext = mockk(relaxed = true) + + every { mockContext.applicationContext } returns mockContext + every { mockContext.getSharedPreferences(any(), any()) } returns mockPrefs + every { mockPrefs.edit() } returns mockEditor + + every { mockEditor.putString(any(), any()) } answers { + prefStorage[firstArg()] = secondArg() + mockEditor + } + every { mockPrefs.getString(any(), any()) } answers { + prefStorage[firstArg()] ?: secondArg() + } + every { mockEditor.remove(any()) } answers { + prefStorage.remove(firstArg()) + mockEditor + } + every { mockEditor.clear() } answers { + prefStorage.clear() + mockEditor + } + every { mockEditor.apply() } returns Unit + + SessionListCacheStore.resetForTesting(mockPrefs) + } + + @After + fun tearDown() { + SessionListCacheStore.resetForTesting(null) + } + + @Test + fun testPutAndGet() { + val sample = + SessionListResponse( + sessions = + listOf( + SessionInfo( + id = "s-123", + title = "Test Session", + ), + ), + total = 1, + ) + + SessionListCacheStore.put("active", sample) + val loaded = SessionListCacheStore.get("active") + + assertNotNull(loaded) + assertEquals(1, loaded?.total) + assertEquals("s-123", loaded?.sessions?.firstOrNull()?.id) + assertEquals("Test Session", loaded?.sessions?.firstOrNull()?.title) + } + + @Test + fun testCacheMiss() { + assertNull(SessionListCacheStore.get("nonexistent")) + } + + @Test + fun testCorruptedJsonReturnsNull() { + prefStorage["sessions_page_corrupt"] = "{ not valid json }" + assertNull(SessionListCacheStore.get("corrupt")) + } + + @Test + fun testScopePartitioningCoexistence() { + val scopeA = DataScope("conn-a", "http://server-a", "profile-a") + val scopeB = DataScope("conn-b", "http://server-b", "profile-b") + + val keyA = scopeA.persistentKey("recent") + val keyB = scopeB.persistentKey("recent") + + val responseA = SessionListResponse(sessions = listOf(SessionInfo(id = "a-1", title = "Session A")), total = 1) + val responseB = SessionListResponse(sessions = listOf(SessionInfo(id = "b-1", title = "Session B")), total = 1) + + SessionListCacheStore.put(keyA, responseA) + SessionListCacheStore.put(keyB, responseB) + + val loadedA = SessionListCacheStore.get(keyA) + val loadedB = SessionListCacheStore.get(keyB) + + assertNotNull(loadedA) + assertNotNull(loadedB) + assertEquals("Session A", loadedA?.sessions?.first()?.title) + assertEquals("Session B", loadedB?.sessions?.first()?.title) + + // Removing keyA does not remove keyB + SessionListCacheStore.remove(keyA) + assertNull(SessionListCacheStore.get(keyA)) + assertNotNull(SessionListCacheStore.get(keyB)) + } + + @Test + fun testClearRemovesAll() { + val sample = SessionListResponse(sessions = emptyList(), total = 0) + SessionListCacheStore.put("k1", sample) + SessionListCacheStore.put("k2", sample) + + SessionListCacheStore.clear() + assertNull(SessionListCacheStore.get("k1")) + assertNull(SessionListCacheStore.get("k2")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt new file mode 100644 index 00000000..2e783bd2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt @@ -0,0 +1,85 @@ +package com.m57.hermescontrol.data.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SwrCacheTest { + @Test + fun testCacheHitReturnsValue() { + val cache = SwrCache() + cache.put("k1", "v1") + assertEquals("v1", cache.get("k1")) + } + + @Test + fun testCacheMissReturnsNull() { + val cache = SwrCache() + assertNull(cache.get("nonexistent")) + } + + @Test + fun testTtlExpiration() { + var currentTime = 1000L + val cache = + SwrCache( + ttlMillis = 500L, + timeProvider = { currentTime }, + ) + + cache.put("k1", "v1") + assertEquals("v1", cache.get("k1")) + + // Advance past TTL + currentTime = 1600L + assertNull(cache.get("k1")) + assertEquals(0, cache.size) + } + + @Test + fun testLruEviction() { + val cache = SwrCache(maxCapacity = 2) + cache.put("k1", "v1") + cache.put("k2", "v2") + cache.put("k3", "v3") + + // k1 should have been evicted + assertNull(cache.get("k1")) + assertEquals("v2", cache.get("k2")) + assertEquals("v3", cache.get("k3")) + assertEquals(2, cache.size) + } + + @Test + fun testClearAndRemove() { + val cache = SwrCache() + cache.put("k1", "v1") + cache.put("k2", "v2") + + val removed = cache.remove("k1") + assertEquals("v1", removed) + assertNull(cache.get("k1")) + assertEquals(1, cache.size) + + cache.clear() + assertEquals(0, cache.size) + assertNull(cache.get("k2")) + } + + @Test + fun testThreadSafetyConcurrentAccess() { + val cache = SwrCache(maxCapacity = 100) + val threads = + List(10) { threadIdx -> + Thread { + for (i in 0 until 500) { + cache.put("k-$threadIdx-$i", i) + cache.get("k-$threadIdx-$i") + if (i % 5 == 0) cache.remove("k-$threadIdx-$i") + } + } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt index 84f4844f..76b42b72 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt @@ -5,6 +5,7 @@ import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -32,50 +33,68 @@ class DashboardSessionTokenRefresherTest { } @Test - fun fetchExtractsInjectedDashboardToken() { + fun fetchExtractsInjectedDashboardToken() = + runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertEquals("new-token", token) + assertEquals("/", server.takeRequest().path) + } + + @Test + fun fetchReturnsNullWhenDashboardDoesNotInjectToken() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("")) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } + + @Test + fun fetchReturnsNullForFailedResponse() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } + + @Test + fun fetchReturnsNullWhenTokenIsBlank() = + runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } + + @Test + fun fetchSyncExtractsInjectedDashboardToken() { server.enqueue( MockResponse() .setResponseCode(200) - .setBody(""""""), + .setBody(""""""), ) - val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + val token = DashboardSessionTokenRefresher.fetchSync(server.url("/").toString(), OkHttpClient()) - assertEquals("new-token", token) + assertEquals("sync-token", token) assertEquals("/", server.takeRequest().path) } - @Test - fun fetchReturnsNullWhenDashboardDoesNotInjectToken() { - server.enqueue(MockResponse().setResponseCode(200).setBody("")) - - val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) - - assertNull(token) - } - - @Test - fun fetchReturnsNullForFailedResponse() { - server.enqueue(MockResponse().setResponseCode(500)) - - val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) - - assertNull(token) - } - - @Test - fun fetchReturnsNullWhenTokenIsBlank() { - server.enqueue( - MockResponse() - .setResponseCode(200) - .setBody(""""""), - ) - - val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) - - assertNull(token) - } - @Test fun refreshUpdatesAuthManagerOnSuccess() { mockkObject(AuthManager) @@ -94,6 +113,25 @@ class DashboardSessionTokenRefresherTest { verify { AuthManager.setToken("mocked-token") } } + @Test + fun refreshAsyncUpdatesAuthManagerOnSuccess() = + runBlocking { + mockkObject(AuthManager) + every { AuthManager.baseUrl() } returns server.url("/").toString() + every { AuthManager.setToken(any()) } returns Unit + + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + + val token = DashboardSessionTokenRefresher.refreshAsync() + + assertEquals("async-token", token) + verify { AuthManager.setToken("async-token") } + } + @Test fun refreshReturnsNullOnException() { mockkObject(AuthManager) diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkMonitorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkMonitorTest.kt index 18a4b7a8..d9863fcf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkMonitorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkMonitorTest.kt @@ -2,10 +2,10 @@ package com.m57.hermescontrol.data.remote import android.net.ConnectivityManager import android.net.Network +import android.net.NetworkCapabilities import app.cash.turbine.test import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -15,13 +15,16 @@ import org.junit.Test class NetworkMonitorTest { @Before fun setUp() { - val defaultNetworkField = NetworkMonitor::class.java.getDeclaredField("defaultNetwork") - defaultNetworkField.isAccessible = true - defaultNetworkField.set(NetworkMonitor, null) - val connectedField = NetworkMonitor::class.java.getDeclaredField("_isConnected") - connectedField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (connectedField.get(NetworkMonitor) as MutableStateFlow).value = true + NetworkMonitor.resetForTest() + } + + private fun createCapabilities(vararg transports: Int): NetworkCapabilities { + val capabilities = mockk() + every { capabilities.hasTransport(any()) } answers { + val transport = firstArg() + transports.contains(transport) + } + return capabilities } @Test @@ -94,4 +97,136 @@ class NetworkMonitorTest { expectNoEvents() } } + + @Test + fun `initial capabilities callback after onAvailable sets baseline and does not emit transport change`() = + runTest { + val connectivityManager = mockk() + val network = mockk() + val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI) + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(network) + callback.onCapabilitiesChanged(network, wifiCaps) + + expectNoEvents() + } + } + + @Test + fun `capabilities change with different transports emits transport change`() = + runTest { + val connectivityManager = mockk() + val network = mockk() + val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI) + val vpnWifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_VPN) + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(network) + callback.onCapabilitiesChanged(network, wifiCaps) + expectNoEvents() + + callback.onCapabilitiesChanged(network, vpnWifiCaps) + assertEquals(Unit, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `capabilities change with identical transports does not emit transport change`() = + runTest { + val connectivityManager = mockk() + val network = mockk() + val wifiCaps1 = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI) + val wifiCaps2 = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI) + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(network) + callback.onCapabilitiesChanged(network, wifiCaps1) + expectNoEvents() + + callback.onCapabilitiesChanged(network, wifiCaps2) + expectNoEvents() + } + } + + @Test + fun `capabilities change on non-default network is ignored`() = + runTest { + val connectivityManager = mockk() + val defaultNet = mockk() + val otherNet = mockk() + val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI) + val cellularCaps = createCapabilities(NetworkCapabilities.TRANSPORT_CELLULAR) + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(defaultNet) + callback.onCapabilitiesChanged(defaultNet, wifiCaps) + expectNoEvents() + + callback.onCapabilitiesChanged(otherNet, cellularCaps) + expectNoEvents() + } + } + + @Test + fun `onLosing on default network emits transport change`() = + runTest { + val connectivityManager = mockk() + val defaultNet = mockk() + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(defaultNet) + callback.onLosing(defaultNet, 3000) + + assertEquals(Unit, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `onLosing on non-default network is ignored`() = + runTest { + val connectivityManager = mockk() + val defaultNet = mockk() + val otherNet = mockk() + val callback = NetworkMonitor.createCallback(connectivityManager) + + NetworkMonitor.transportChanges.test { + callback.onAvailable(defaultNet) + callback.onLosing(otherNet, 3000) + + expectNoEvents() + } + } + + @Test + fun `extractTransports identifies all supported transport types`() { + val caps = + createCapabilities( + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_VPN, + NetworkCapabilities.TRANSPORT_BLUETOOTH, + NetworkCapabilities.TRANSPORT_WIFI_AWARE, + ) + val transports = NetworkMonitor.extractTransports(caps) + assertEquals( + setOf( + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_VPN, + NetworkCapabilities.TRANSPORT_BLUETOOTH, + NetworkCapabilities.TRANSPORT_WIFI_AWARE, + ), + transports, + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt index 1dc9f516..af078c6c 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt @@ -14,11 +14,15 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals @@ -35,16 +39,29 @@ import retrofit2.Response */ @OptIn(ExperimentalCoroutinesApi::class) class ProfileSwitchCoordinatorTest { + private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService @Before fun setUp() { - // NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock - // bleeds into later test classes in the same JVM (it hijacks - // Dispatchers.IO for HermesWsClient's reconnect coroutines), which - // deterministically broke HermesWsClientTest.testAutoReconnect in - // full-suite runs. Real Dispatchers.IO is fine: the network layer is - // mocked, so withContext(Dispatchers.IO) hops are instant. + // setMain is REQUIRED, and it is NOT the thing that used to bleed. + // + // The leak was mockkStatic(Dispatchers::class) -- absent here, keep it that + // way. Avoiding setMain as well was the actual bug: with no setMain, + // Dispatchers.Main is the JVM's "missing" dispatcher, and switchProfile's + // _switched.tryEmit resumes collectors onto Main. Once ANY earlier class in + // the same test JVM has installed and then reset a TestMainDispatcher, + // dispatching to Main throws -- which is exactly why this class passed 5/5 + // in isolation but lost 4 tests in full-suite runs (2x DispatchException + // "Dispatchers.Main threw an exception", 2x "test body did not run to + // completion"). Every other test class here already does this. + Dispatchers.setMain(testDispatcher) + // Drive the coordinator's network hops on the test dispatcher instead of a + // live IO thread pool. With real Dispatchers.IO the mocked calls were + // recorded from a different thread and the Ordering.SEQUENCE assertions + // became load-dependent -- green on an idle machine, 2 failures while the + // emulator saturated the CPU. + ProfileSwitchCoordinator.ioDispatcher = testDispatcher mockkObject(ApiClient) mockApi = mockk(relaxed = true) @@ -60,6 +77,8 @@ class ProfileSwitchCoordinatorTest { @After fun tearDown() { + ProfileSwitchCoordinator.ioDispatcher = Dispatchers.IO + Dispatchers.resetMain() unmockkAll() } @@ -72,7 +91,18 @@ class ProfileSwitchCoordinatorTest { assertTrue(result is NetworkResult.Success) coVerify { mockApi.setActiveProfile(SetActiveProfileRequest("meow")) } - verify(ordering = Ordering.SEQUENCE) { + // Ordering.ORDERED, not SEQUENCE: the point of this test is the relative + // ORDER of the switch steps. SEQUENCE also demands that NO other call + // touches the mocked objects in between, which is untestable in a shared + // test JVM -- real-thread tests (HermesWsClientTest) leave background + // ticket-mint retries calling AuthManager.getBaseUrl()/getServerStore() + // for the rest of the process. Under load those 18 foreign calls landed + // inside the verification window and failed the exact-count check even + // though all 5 steps fired in the correct order (proven by the MockK + // call trace: 1) setActiveProfileId 2) rebuild ... 21) sync 22) + // disconnect 23) connect). ORDER keeps the order-pinning and ignores + // foreign traffic. + verify(ordering = Ordering.ORDERED) { AuthManager.setActiveProfileId("meow") HermesWsClient.disconnect() HermesWsClient.connect() @@ -128,7 +158,12 @@ class ProfileSwitchCoordinatorTest { // server's cookie → 401 → dead socket), then the socket re-dials // — so chat's wipe (via the broadcast) lands before the new // gateway's gateway.ready auto-creates the fresh session. - coVerify(ordering = Ordering.SEQUENCE) { + // + // ORDERED, not SEQUENCE -- see the sibling test above: SEQUENCE's + // exact-count semantics fail under load in the shared test JVM when + // leaked background retries interleave foreign AuthManager/ApiClient + // calls into the window. ORDER still pins this 5-step sequence. + coVerify(ordering = Ordering.ORDERED) { AuthManager.setSelectedProfileId("prof-2") ApiClient.rebuild() AuthManager.syncCookieStoreForProfile("prof-2") diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt new file mode 100644 index 00000000..4bd6a811 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt @@ -0,0 +1,158 @@ +package com.m57.hermescontrol.data.update + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Endpoint contract for the opt-in release-candidate channel: the stable path + * must keep using `releases/latest` (GitHub excludes pre-releases there), and + * only the opt-in path may scan the releases list. + */ +class AppUpdateCheckerNetworkTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun checker(): AppUpdateChecker = + AppUpdateChecker( + client = OkHttpClient(), + apiBaseUrl = server.url("/repos/Hy4ri/hermes-mobile").toString(), + ) + + private fun enqueue( + body: String, + code: Int = 200, + ) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun fetchLatestRelease_defaultUsesStableLatestEndpoint() = + runTest { + enqueue(STABLE_BODY) + + val info = checker().fetchLatestRelease() + + assertEquals("v1.24.2", info?.tagName) + assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path) + } + + @Test + fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() = + runTest { + enqueue(RELEASES_BODY) + + val info = checker().fetchLatestRelease(includeReleaseCandidates = true) + + assertEquals("v1.25.0-rc.3", info?.tagName) + assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=20", server.takeRequest().path) + } + + @Test + fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() = + runTest { + enqueue(ALPHA_BETA_BODY) + + assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true)) + } + + @Test + fun fetchLatestRelease_nonSuccessYieldsNull() = + runTest { + // One queued response per request: stable path, then RC path. + enqueue("", code = 404) + enqueue("", code = 404) + + assertNull(checker().fetchLatestRelease()) + assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true)) + } + + private companion object { + const val STABLE_BODY = """ +{ + "tag_name": "v1.24.2", + "prerelease": false, + "draft": false, + "assets": [ + { + "name": "hermes-mobile-v1.24.2.apk", + "size": 12345, + "browser_download_url": "https://example.com/stable.apk" + } + ] +} +""" + + const val RELEASES_BODY = """ +[ + { + "tag_name": "v1.25.0-rc.3", + "prerelease": true, + "draft": false, + "assets": [ + { + "name": "hermes-mobile-v1.25.0-rc.3.apk", + "size": 999, + "browser_download_url": "https://example.com/rc.apk" + } + ] + }, + { + "tag_name": "v1.24.2", + "prerelease": false, + "draft": false, + "assets": [ + { + "name": "hermes-mobile-v1.24.2.apk", + "size": 12345, + "browser_download_url": "https://example.com/stable.apk" + } + ] + } +] +""" + + const val ALPHA_BETA_BODY = """ +[ + { + "tag_name": "v1.30.0-alpha.1", + "prerelease": true, + "assets": [ + { + "name": "hermes-mobile-v1.30.0-alpha.1.apk", + "size": 1, + "browser_download_url": "https://example.com/alpha.apk" + } + ] + }, + { + "tag_name": "v1.29.0-beta.4", + "prerelease": true, + "assets": [ + { + "name": "hermes-mobile-v1.29.0-beta.4.apk", + "size": 1, + "browser_download_url": "https://example.com/beta.apk" + } + ] + } +] +""" + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt index 3ffbe871..7efd13f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt @@ -35,12 +35,36 @@ class AppUpdateCheckerTest { } @Test - fun isNewerVersion_nonNumericSuffixCountsAsZero() { + fun isNewerVersion_newerCoreOutranksDevBuild() { // Local dev default "1.0-dev" must still see a real release as newer. assertTrue(isNewerVersion("1.21.0", "1.0-dev")) assertTrue(isNewerVersion("v1.21.0", "1.0-dev")) } + @Test + fun isNewerVersion_preReleaseSuffixesSortBelowStableBase() { + // A versionName built from a release-candidate tag ("1.25.0-rc.1", + // or the dot-form "1.25.rc.1") must see the stable release of the + // same base as a NEWER update. Previously the "rc" segment parsed + // as 0 with a stray trailing segment, so an installed rc compared + // GREATER than the stable tag and the update prompt never fired. + assertTrue(isNewerVersion("v1.25", "1.25.rc.1")) + assertTrue(isNewerVersion("v1.25", "1.25.0-rc.1")) + assertTrue(isNewerVersion("v1.25", "1.25-rc.9")) + assertTrue(isNewerVersion("v1.26.0", "1.25.0-rc.2")) + assertTrue(isNewerVersion("v1.28.1", "1.28.0-rc.3")) + assertTrue(isNewerVersion("1.25.0-rc.3", "1.25.0-rc.2")) + // Equal versions — including rc == rc — must stay silent. + assertFalse(isNewerVersion("v1.25", "1.25")) + assertFalse(isNewerVersion("v1.25.0-rc.2", "1.25.0-rc.2")) + // A stable install must not be prompted by a same-base rc release. + assertFalse(isNewerVersion("v1.25.0-rc.2", "1.25.0")) + // Numeric growth still wins over any pre-release marker. + assertTrue(isNewerVersion("v1.26", "1.25.0-rc.5")) + // Local dev builds still see any real release as newer. + assertTrue(isNewerVersion("v1.21.0", "1.0-dev")) + } + @Test fun isNewerVersion_unparseableNeverClaimsUpdate() { assertFalse(isNewerVersion("not-a-version", "1.21.0")) @@ -48,6 +72,44 @@ class AppUpdateCheckerTest { assertFalse(isNewerVersion("", "1.21.0")) } + @Test + fun isNewerVersion_comparesCoreBeforePrerelease() { + assertTrue(isNewerVersion("1.25.1-rc.1", "1.25")) + assertFalse(isNewerVersion("1.25", "1.25.1-rc.1")) + assertTrue(isNewerVersion("1.25.0.1", "1.25-rc.9")) + assertFalse(isNewerVersion("1.24.9", "1.25.rc.1")) + assertFalse(isNewerVersion("1.25", "1.25.0")) + assertFalse(isNewerVersion("1.25.0-rc.1", "1.25.rc.1")) + assertFalse(isNewerVersion("1.25.rc.1", "1.25.0-rc.1")) + } + + @Test + fun isNewerVersion_ordersPrereleaseIdentifiers() { + val versions = listOf("1.25-alpha", "1.25-alpha.1", "1.25-beta", "1.25-rc.2", "1.25-rc.10", "1.25") + versions.zipWithNext().forEach { (older, newer) -> + assertTrue("$newer > $older", isNewerVersion(newer, older)) + assertFalse("$older < $newer", isNewerVersion(older, newer)) + } + assertTrue(isNewerVersion("1.25-rc.99999999999999999999", "1.25-rc.10")) + assertTrue(isNewerVersion("1.25-rc.a", "1.25-rc.10")) + assertTrue(isNewerVersion("1.25", "1.25-dev")) + } + + @Test + fun isNewerVersion_ignoresBuildMetadata() { + assertFalse(isNewerVersion("1.25+build.2", "1.25+build.1")) + assertFalse(isNewerVersion("1.25", "1.25+build.1")) + assertTrue(isNewerVersion(" v1.25+build.2 ", "1.25-rc.1+build.3")) + } + + @Test + fun isNewerVersion_rejectsMalformedVersionsOnEitherSide() { + listOf("garbage", "1..25", "1.25-", "1.25+", "1.25_rc.1", "1.25!", "99999999999999999999").forEach { + assertFalse(it, isNewerVersion(it, "1.25")) + assertFalse(it, isNewerVersion("1.25", it)) + } + } + @Test fun normalizedVersion_stripsLeadingV() { assertEquals("1.21.0", normalizedVersion("v1.21.0")) @@ -113,4 +175,159 @@ class AppUpdateCheckerTest { assertNull(parseUpdateInfo("not json at all")) assertNull(parseUpdateInfo("")) } + + // ── Release candidate channel (opt-in) ────────────────────────────── + + private fun release( + tag: String, + draft: Boolean = false, + prerelease: Boolean = false, + withApk: Boolean = true, + ): UpdateInfo = + UpdateInfo( + tagName = tag, + draft = draft, + prerelease = prerelease, + assets = + if (withApk) { + listOf( + UpdateInfo.Asset( + name = "hermes-mobile-$tag.apk", + size = 1L, + browserDownloadUrl = "https://example.com/$tag.apk", + ), + ) + } else { + listOf( + UpdateInfo.Asset( + name = "version.txt", + size = 1L, + browserDownloadUrl = "https://example.com/version.txt", + ), + ) + }, + ) + + @Test + fun isReleaseCandidateVersion_acceptsRcTagForms() { + listOf("v1.25.0-rc.1", "1.25-rc.1", "1.25.rc.1", "v1.25.0-rc", "1.25-rc1", "v1.25.0-RC.2").forEach { + assertTrue(it, isReleaseCandidateVersion(it)) + } + } + + @Test + fun isReleaseCandidateVersion_rejectsStableAndNonRcPrereleases() { + listOf("1.25", "v1.25.0", "1.25-alpha", "1.25-alpha.1", "1.25-beta.2", "1.25-dev", "garbage", "").forEach { + assertFalse(it, isReleaseCandidateVersion(it)) + } + } + + @Test + fun selectLatestUpdate_skipsDraftsAndReleasesWithoutApk() { + val releases = + listOf( + release("v1.30.0", draft = true), + release("v1.29.0", withApk = false), + release("v1.28.0"), + ) + + assertEquals("v1.28.0", selectLatestUpdate(releases)?.tagName) + } + + @Test + fun selectLatestUpdate_excludesRcUnlessOptedIn() { + val releases = + listOf( + release("v1.25.0-rc.3", prerelease = true), + release("v1.24.2"), + ) + + assertEquals("v1.24.2", selectLatestUpdate(releases)?.tagName) + assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName) + } + + @Test + fun selectLatestUpdate_neverOffersAlphaOrBetaEvenWhenOptedIn() { + val releases = + listOf( + release("v1.30.0-alpha.1", prerelease = true), + release("v1.29.0-beta.4", prerelease = true), + release("v1.28.0-rc.1", prerelease = true), + ) + + assertEquals("v1.28.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName) + assertNull(selectLatestUpdate(releases)) + } + + @Test + fun selectLatestUpdate_prefersStableWhenItIsNewerThanTheRc() { + val releases = + listOf( + release("v1.26.0"), + release("v1.25.0-rc.3", prerelease = true), + ) + + // List order is publication order, not version order — the stable + // release must win on semantics, not on position. + assertEquals("v1.26.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName) + } + + @Test + fun selectLatestUpdate_prefersNewerRcOverOlderStable() { + val releases = + listOf( + release("v1.24.2"), + release("v1.25.0-rc.3", prerelease = true), + ) + + assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName) + } + + @Test + fun selectLatestUpdate_nullWhenNothingInstallable() { + assertNull(selectLatestUpdate(emptyList())) + assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false)))) + } + + @Test + fun parseReleaseList_readsDraftAndPrereleaseFlags() { + val json = + """ + [ + { + "tag_name": "v1.25.0-rc.3", + "prerelease": true, + "draft": false, + "assets": [ + { + "name": "hermes-mobile-v1.25.0-rc.3.apk", + "size": 999, + "browser_download_url": "https://example.com/rc.apk" + } + ] + }, + { + "tag_name": "v1.24.2", + "assets": [] + } + ] + """.trimIndent() + + val releases = parseReleaseList(json) + assertNotNull(releases) + assertEquals(2, releases!!.size) + assertEquals("v1.25.0-rc.3", releases[0].tagName) + assertTrue(releases[0].prerelease) + assertFalse(releases[0].draft) + assertNotNull(releases[0].apkAsset) + // Absent flags default to a plain stable release with no APK. + assertFalse(releases[1].prerelease) + assertNull(releases[1].apkAsset) + } + + @Test + fun parseReleaseList_malformedJson_yieldsNull() { + assertNull(parseReleaseList("not json at all")) + assertNull(parseReleaseList("{}")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt index 9edd89d5..06195d26 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt @@ -62,6 +62,8 @@ class UpdateNoticeManagerTest { every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit every { AuthManager.getDismissedUpdateTag() } returns null every { AuthManager.setDismissedUpdateTag(any()) } returns Unit + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false + every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit } @After @@ -131,6 +133,27 @@ class UpdateNoticeManagerTest { assertNull(UpdateNoticeManager.noticeTag(currentVersion)) } + @Test + fun checkOnLaunch_stableReleasePromptsInstalledRc() = + runTest { + val checker = mockk() + coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.25") + + UpdateNoticeManager.checkOnLaunch(checker, "1.25.0-rc.1", testDispatcher) + advanceUntilIdle() + + assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable) + assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.0-rc.1")) + verify { AuthManager.setLastKnownLatestTag("v1.25") } + } + + @Test + fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() { + every { AuthManager.getLastKnownLatestTag() } returns "v1.25" + assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.rc.1")) + assertNull(UpdateNoticeManager.noticeTag("1.25")) + } + @Test fun checkOnLaunch_upToDateWhenSameVersion() = runTest { @@ -238,4 +261,85 @@ class UpdateNoticeManagerTest { assertNull(UpdateNoticeManager.noticeTag(currentVersion)) } + + // ── Release candidate channel (opt-in) ────────────────────────────── + + @Test + fun checkOnLaunch_staysOnStableChannelByDefault() = + runTest { + val checker = mockk() + coEvery { checker.fetchLatestRelease(false) } returns updateInfo() + + UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) + advanceUntilIdle() + + coVerify(exactly = 1) { checker.fetchLatestRelease(false) } + coVerify(exactly = 0) { checker.fetchLatestRelease(true) } + } + + @Test + fun checkOnLaunch_scansReleaseCandidatesWhenOptedIn() = + runTest { + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + val checker = mockk() + coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3") + + UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) + advanceUntilIdle() + + coVerify(exactly = 1) { checker.fetchLatestRelease(true) } + assertEquals( + "v1.25.0-rc.3", + (AppUpdateCache.state.value as AppUpdateState.UpdateAvailable).latestTag, + ) + } + + @Test + fun noticeTag_suppressesPersistedRcTagOnStableChannel() { + every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3" + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false + + assertNull(UpdateNoticeManager.noticeTag(currentVersion)) + } + + @Test + fun noticeTag_advertisesPersistedRcTagWhenOptedIn() { + every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3" + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + + assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion)) + } + + @Test + fun noticeTag_suppressesCachedRcOfferOnStableChannel() { + AppUpdateCache.update( + AppUpdateState.UpdateAvailable( + latestTag = "v1.25.0-rc.3", + apkUrl = "https://example.com/apk", + sizeBytes = 1L, + ), + ) + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false + + assertNull(UpdateNoticeManager.noticeTag(currentVersion)) + } + + @Test + fun checkOnLaunch_discardsRcResultWhenChannelFlippedMidRequest() = + runTest { + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + val checker = mockk() + coEvery { checker.fetchLatestRelease(true) } coAnswers { + // The user turns the RC channel off while the request is in flight. + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false + updateInfo(tag = "v1.25.0-rc.3") + } + + UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) + advanceUntilIdle() + + // The late RC result must not reach the cache or the persisted tag. + assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value) + verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 5f79c483..70a8f0cf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -980,7 +980,12 @@ class EventParserTest { mapOf( "type" to "tool.progress", "session_id" to "sess-123", - "payload" to mapOf("name" to "web_search", "preview" to "downloading content..."), + "payload" to + mapOf( + "tool_id" to "call-progress", + "name" to "web_search", + "preview" to "downloading content...", + ), ), ) val event = EventParser.parse(response) @@ -989,6 +994,7 @@ class EventParserTest { assertEquals("web_search", toolProgress.name) assertEquals("downloading content...", toolProgress.preview) assertEquals("sess-123", toolProgress.sessionId) + assertEquals("call-progress", toolProgress.toolId) } @Test @@ -1004,7 +1010,7 @@ class EventParserTest { mapOf( "type" to "tool.generating", "session_id" to "sess-123", - "payload" to mapOf("name" to "code_writer"), + "payload" to mapOf("tool_id" to "call-generating", "name" to "code_writer"), ), ) val event = EventParser.parse(response) @@ -1012,6 +1018,7 @@ class EventParserTest { val toolGenerating = event as WsEvent.ToolGenerating assertEquals("code_writer", toolGenerating.name) assertEquals("sess-123", toolGenerating.sessionId) + assertEquals("call-generating", toolGenerating.toolId) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index dff70f1a..e3df3221 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar import com.m57.hermescontrol.data.session.ActiveSessionHolder +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -17,6 +18,7 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -84,6 +86,10 @@ class HermesWsClientTest { // the WS stack can build its OkHttp clients without app context. CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + // Reset state val connectedField = HermesWsClient::class.java.getDeclaredField("connected") connectedField.isAccessible = true @@ -101,6 +107,7 @@ class HermesWsClientTest { HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.setAppForeground(true) val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") acceptQueuedMessagesField.isAccessible = true @@ -110,6 +117,7 @@ class HermesWsClientTest { @After fun tearDown() { HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.disconnect(clearPendingMessages = true) // Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer // Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake @@ -169,6 +177,138 @@ class HermesWsClientTest { assertTrue(msg.contains(id)) } + @Test + fun gatewayReadyAdvertisesServerRequestCapability() { + val capabilityLatch = CountDownLatch(1) + var capabilityFrame: String? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + webSocket.send( + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{}}}""", + ) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = Json.parseToJsonElement(text).jsonObject + if (frame["method"]?.jsonPrimitive?.content == WsMethods.CLIENT_CAPABILITIES) { + capabilityFrame = text + capabilityLatch.countDown() + } + } + }, + ), + ) + + HermesWsClient.connect() + + assertTrue("Capability advertisement not sent", capabilityLatch.await(5, TimeUnit.SECONDS)) + val frame = Json.parseToJsonElement(capabilityFrame ?: "{}").jsonObject + assertEquals(WsMethods.CLIENT_CAPABILITIES, frame["method"]?.jsonPrimitive?.content) + assertTrue( + frame["params"] + ?.jsonObject + ?.get("server_requests") + ?.jsonPrimitive + ?.content == "true", + ) + } + + @Test + fun gatewayReadyCapabilityResponsesAreNotPublished() = + runBlocking { + val capabilityLatch = CountDownLatch(1) + val capabilitySuccessLatch = CountDownLatch(1) + val ordinaryErrorLatch = CountDownLatch(1) + var serverWebSocket: WebSocket? = null + var ordinaryRequestId: String? = null + val capabilityResponseCount = AtomicInteger(0) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + webSocket.send( + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{}}}""", + ) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = Json.parseToJsonElement(text).jsonObject + val method = frame["method"]?.jsonPrimitive?.content + val id = frame["id"]?.jsonPrimitive?.content ?: return + when (method) { + WsMethods.CLIENT_CAPABILITIES -> { + if (capabilityResponseCount.getAndIncrement() == 0) { + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","error":{"code":-32601,"message":"unknown method: client.capabilities"}}""", + ) + capabilityLatch.countDown() + } else { + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"server_requests":["clarify"]}}""", + ) + capabilitySuccessLatch.countDown() + } + } + + "ordinary_method" -> { + ordinaryRequestId = id + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","error":{"code":4000,"message":"ordinary test failure"}}""", + ) + ordinaryErrorLatch.countDown() + } + } + } + }, + ), + ) + + val firstRpcResponse = + async(start = CoroutineStart.UNDISPATCHED) { + withTimeout(5_000) { + HermesWsClient.events.first { it is WsEvent.RpcError || it is WsEvent.RpcResult } + } + } + + HermesWsClient.connect() + + assertTrue("Capability request not rejected", capabilityLatch.await(5, TimeUnit.SECONDS)) + assertTrue( + "Second gateway.ready was not sent", + serverWebSocket?.send( + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{}}}""", + ) == true, + ) + assertTrue("Capability success was not received", capabilitySuccessLatch.await(5, TimeUnit.SECONDS)) + val sentOrdinaryRequestId = HermesWsClient.send("ordinary_method") + assertTrue("Ordinary request not rejected", ordinaryErrorLatch.await(5, TimeUnit.SECONDS)) + + val publishedEvent = firstRpcResponse.await() + assertTrue("Capability response leaked into the event stream", publishedEvent is WsEvent.RpcError) + val publishedError = publishedEvent as WsEvent.RpcError + assertEquals(sentOrdinaryRequestId, publishedError.id) + assertEquals(ordinaryRequestId, publishedError.id) + assertEquals(4000, publishedError.error.code) + assertEquals("ordinary test failure", publishedError.error.message) + } + @Test fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() = runBlocking { @@ -180,7 +320,9 @@ class HermesWsClientTest { response: okhttp3.Response, ) { webSocket.send( - """{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret","method":"secret","params":{"session_id":"session-1","env_var":"API_KEY","prompt":"Enter API key"}}]}}""", + """{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret",""" + + """"method":"secret","params":{"session_id":"session-1","env_var":"API_KEY",""" + + """"prompt":"Enter API key"}}]}}""", ) } }, @@ -848,12 +990,11 @@ class HermesWsClientTest { @Test fun testDisconnectThenConnectSupersedesBlockedSocketOpen() = runBlocking { - mockkObject(DashboardSessionTokenRefresher) every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString() val refreshStarted = CountDownLatch(1) val releaseRefresh = CountDownLatch(1) val refreshCalls = AtomicInteger(0) - every { DashboardSessionTokenRefresher.fetch(any(), any()) } answers { + coEvery { DashboardSessionTokenRefresher.refreshAsync() } answers { if (refreshCalls.getAndIncrement() == 0) { refreshStarted.countDown() releaseRefresh.await(5, TimeUnit.SECONDS) @@ -967,6 +1108,23 @@ class HermesWsClientTest { assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) } + @Test + fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() { + mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) + HermesWsClient.connect() + runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } + + HermesWsClient.acquireBackgroundConnectionLease() + HermesWsClient.setAppForeground(false) + + assertTrue(HermesWsClient.isConnected) + + HermesWsClient.releaseBackgroundConnectionLease() + + assertFalse(HermesWsClient.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) + } + @Test fun testBackgroundWithPendingReplyStaysConnected() { mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) @@ -1179,10 +1337,8 @@ class HermesWsClientTest { // 5-6s latch windows routinely overshoot under parallel load). HermesWsClient.setReconnectBackoffForTest(0L) // The reconnect path refreshes the WS token over the network before - // opening the socket. Stub it so no real HTTP call sits inside the + // opening the socket. Stubbed in setUp() so no real HTTP call sits inside the // test's timing window (CI network latency was the dominant flake). - mockkObject(DashboardSessionTokenRefresher) - every { DashboardSessionTokenRefresher.fetch(any(), any()) } returns null var serverSocket1: WebSocket? = null var serverSocket2: WebSocket? = null @@ -1521,6 +1677,11 @@ class HermesWsClientTest { HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) ticketServer.shutdown() } @@ -1547,6 +1708,11 @@ class HermesWsClientTest { HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.RECONNECTING } + } + } assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value) } @@ -1671,7 +1837,10 @@ class HermesWsClientTest { val tokenALatch = CountDownLatch(1) val tokenBLatch = CountDownLatch(1) val collectorJob = - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + // Subscribe before sending A: this SharedFlow does not replay missed tokens (#1163). + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch( + start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED, + ) { HermesWsClient.events.collect { event -> if (event is WsEvent.MessageToken) { receivedTokens.add(event.token) @@ -1826,6 +1995,47 @@ class HermesWsClientTest { collectorJob.cancel() } + @Test + fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() { + // Issue #1163: exercise the real listener, including the early duplicate return. + val socket = mockk(relaxed = true) + val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalClose.isAccessible = true + (intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generation.isAccessible = true + val constructor = + Class + .forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") + .declaredConstructors + .single() + constructor.isAccessible = true + val listener = + constructor.newInstance( + (generation.get(HermesWsClient) as AtomicInteger).get(), + ) as WebSocketListener + // Directly install the reflection-built listener so onMessage runs even + // though this test never performed a real OkHttp connect. + val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") + socketField.isAccessible = true + socketField.set(HermesWsClient, socket) + mockkObject(EventParser) + + val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]") + for ((index, seq) in seqValues.withIndex()) { + val sid = "stream-$index" + HermesWsClient.setSeqWatermark(sid, 5) + val text = + """{"method":"event","params":{"type":"message.token","session_id":false,"seq":$seq,""" + + """"payload":{"session_id":"$sid","text":"chunk"}}}""" + listener.onMessage(socket, text) + listener.onMessage(socket, text) + val numeric = index < 3 + assertEquals(if (numeric) 6 else 5, HermesWsClient.getSeqWatermarks()[sid]) + verify(exactly = if (numeric) 1 else 2) { EventParser.parse(any(), text) } + } + } + @Test fun testTerminalCloseCode4403SetsAuthExpired() { val socket = mockk(relaxed = true) @@ -1969,4 +2179,85 @@ class HermesWsClientTest { assertEquals(latency, HermesWsClient.lastLatencyMs.value) assertTrue(HermesWsClient.lastPongTimestamp > 0) } + + @Test + fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() { + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + + val failureLatch = CountDownLatch(1) + HermesWsClient.probeLivenessOnTransportChange( + onFailureAction = { + failureLatch.countDown() + }, + ) + + assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS)) + } + + @Test + fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() { + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + + var failureInvoked = false + HermesWsClient.probeLivenessOnTransportChange( + onFailureAction = { + failureInvoked = true + }, + ) + + Thread.sleep(100) + assertFalse(failureInvoked) + } + + @Test + fun testProbeLivenessOnTransportChangeSucceedsWhenServerAnswers() { + val serverLatch = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(""""method":"gateway.ping"""") || text.contains(""""method":"ping"""")) { + val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"pong":true,"timestamp":1700000000.0}}""", + ) + } + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + + var failureInvoked = false + HermesWsClient.probeLivenessOnTransportChange( + onFailureAction = { + failureInvoked = true + }, + ) + + Thread.sleep(500) + assertFalse("Expected socket to remain connected when ping succeeds", failureInvoked) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt new file mode 100644 index 00000000..2bff3c55 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt @@ -0,0 +1,96 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ModelOptionsResponse +import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModelOptionsRepositoryTest { + private val fallback = NetworkResult.Success(ModelOptionsResponse(emptyList())) + + @Test + fun `connected uses raw JSON and preserves refresh flags without REST`() = + runTest { + val repository = + ModelOptionsRepository( + connected = { true }, + request = { params -> + assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params) + Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""") + }, + rest = { error("REST must not run") }, + ) + val result = repository.load(true) as NetworkResult.Success + assertEquals( + "test", + result.data.providers + .single() + .slug, + ) + assertTrue(WsMethods.MODEL_OPTIONS in WsMethods.PROFILE_SCOPED_METHODS) + } + + @Test + fun `disconnected bypasses RPC`() = + runTest { + val repository = + ModelOptionsRepository( + connected = { false }, + request = { error("RPC must not run") }, + rest = { refresh -> + assertTrue(refresh) + fallback + }, + ) + assertEquals(fallback, repository.load(true)) + } + + @Test + fun `RPC failure and malformed envelopes use REST`() = + runTest { + for (wire in listOf(null, "{}", "[]", """{"providers":null}""")) { + var restCalls = 0 + val repository = + ModelOptionsRepository( + connected = { true }, + request = { if (wire == null) error("Unsupported method") else Json.parseToJsonElement(wire) }, + rest = { + restCalls++ + fallback + }, + ) + assertEquals(fallback, repository.load()) + assertEquals(1, restCalls) + } + } + + @Test + fun `cancelled screen never falls back`() = + runTest { + val started = CompletableDeferred() + var restCalls = 0 + val repository = + ModelOptionsRepository( + connected = { true }, + request = { + started.complete(Unit) + awaitCancellation() + }, + rest = { + restCalls++ + fallback + }, + ) + val job = async { repository.load() } + started.await() + job.cancel() + job.join() + assertEquals(0, restCalls) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/StreamingEventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/StreamingEventParserTest.kt new file mode 100644 index 00000000..d33bb09e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/StreamingEventParserTest.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.data.ws + +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.Test + +class StreamingEventParserTest { + private val types = listOf("message.token", "message.delta", "thinking.delta", "reasoning.delta") + + @Test + fun `streaming parsing matches map parser for wire field variants`() { + val sessions = listOf("\"outer\"", "\"\"", "null", "42", "true", "{}", "[]") + val payloads = + listOf( + "null", + "[]", + "42", + "{}", + """{"session_id":"inner","text":"hello 🌙\n"}""", + """{"session_id":null,"text":null}""", + """{"session_id":false,"text":123}""", + """{"session_id":[],"text":true}""", + """{"session_id":{},"text":{}}""", + """{"text":[]}""", + ) + for (type in types) { + for (session in sessions + null) { + for (payload in payloads + null) { + val fields = mutableListOf("\"type\":\"$type\"") + if (session != null) fields.add("\"session_id\":$session") + if (payload != null) fields.add("\"payload\":$payload") + val params = Json.parseToJsonElement("{${fields.joinToString()}}") as JsonObject + + @Suppress("UNCHECKED_CAST") + val legacy = EventParser.parseParams(params.toAny() as Map) + assertEquals( + params.toString(), + legacy, + EventParser.parse(JsonRpcResponse(method = "event", params = params)), + ) + } + } + } + } + + @Test + fun `streaming parsing never traverses params or payload maps`() { + for (type in types) { + val payload = lookupOnlyObject(mapOf("text" to JsonPrimitive("chunk"))) + val params = + lookupOnlyObject( + mapOf( + "type" to JsonPrimitive(type), + "session_id" to JsonPrimitive("session"), + "payload" to payload, + ), + ) + val expected = + when (type) { + "thinking.delta" -> WsEvent.ThinkingDelta("chunk", "session") + "reasoning.delta" -> WsEvent.ReasoningDelta("chunk", "session") + else -> WsEvent.MessageToken("chunk", "session") + } + assertEquals(expected, EventParser.parse(JsonRpcResponse(method = "event", params = params))) + } + } + + private fun lookupOnlyObject(fields: Map): JsonObject = + JsonObject( + object : Map by fields { + override val entries: Set> + get() = error("Streaming path must look up fields, not traverse the JSON tree (#1163)") + }, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt new file mode 100644 index 00000000..2a641154 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -0,0 +1,410 @@ +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.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BackgroundConnectionControllerTest { + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.w(any(), any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + } + + @After + fun tearDown() { + unmockkStatic(Log::class) + } + + @Test + fun testOnAppPause_whenOptIn_acquiresLeaseAndRequestsStart() { + var leaseAcquired = false + var serviceStartRequested = false + var startActionInvoked = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { action -> + serviceStartRequested = true + action() + }, + ) + + controller.onAppPause { + startActionInvoked = true + } + + assertTrue(leaseAcquired) + assertTrue(serviceStartRequested) + assertTrue(startActionInvoked) + } + + @Test + fun testOnAppPause_whenStartThrowsException_rollsBackLeaseAndDoesNotRethrow() { + var leaseAcquired = false + var leaseReleased = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseReleased = true }, + requestServiceStart = { _ -> + throw IllegalStateException("Background start not allowed") + }, + ) + + // Must not rethrow into Activity.onPause, but must roll back acquired lease + controller.onAppPause {} + + assertTrue(leaseAcquired) + assertTrue(leaseReleased) + } + + @Test + fun testOnAppPause_whenOptInOffAndNoPendingReply_doesNothing() { + var leaseAcquired = false + var serviceStartRequested = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { serviceStartRequested = true }, + ) + + controller.onAppPause {} + + assertFalse(leaseAcquired) + assertFalse(serviceStartRequested) + } + + @Test + fun testOnAppPause_whenPendingReplyOnly_requestsServiceWithoutPersistentLease() { + var leaseAcquired = false + var serviceStartRequested = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { serviceStartRequested = true }, + ) + + controller.onAppPause {} + + assertFalse(leaseAcquired) + assertTrue(serviceStartRequested) + } + + @Test + fun testOnAppResume_releasesLeaseAndStopsService() { + var leaseReleased = false + var serviceStopped = false + + val controller = + BackgroundConnectionController( + releaseLease = { leaseReleased = true }, + requestServiceStop = { serviceStopped = true }, + ) + + controller.onAppResume() + + assertTrue(leaseReleased) + assertTrue(serviceStopped) + } + + @Test + fun testOnReplyCompleted_whenOptInOn_keepsServiceAndUpdatesNotification() { + var serviceCompleted = false + var notifiedState: BackgroundNotificationState? = null + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + requestServiceComplete = { serviceCompleted = true }, + onNotificationStateChanged = { state -> notifiedState = state }, + ) + + controller.onReplyCompleted(42L) + + assertFalse(serviceCompleted) + assertEquals(BackgroundNotificationState.ConnectedInBackground, notifiedState) + } + + @Test + fun testOnReplyCompleted_whenOptInOff_completesService() { + var completedGeneration: Long? = null + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + requestServiceComplete = { gen -> completedGeneration = gen }, + ) + + controller.onReplyCompleted(42L) + + assertEquals(42L, completedGeneration) + } + + @Test + fun testDefaultSnapshot_whenNotReady_returnsIneligibleSnapshotWithoutReadingServerStore() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Loading) + every { ChatNotificationService.isAppInForeground() } returns true + + val snapshot = BackgroundConnectionController.defaultSnapshot() + + assertFalse(snapshot.isEligibleForConnection) + assertFalse(snapshot.keepConnectedOptIn) + assertFalse(snapshot.pendingReply) + assertEquals(ConnectionStatus.DISCONNECTED, snapshot.status) + + // Must NOT call any serverStore getters before Ready + verify(exactly = 0) { AuthManager.isGatedMode() } + verify(exactly = 0) { AuthManager.getToken() } + verify(exactly = 0) { AuthManager.isKeepConnectedInBackground() } + verify(exactly = 0) { AuthManager.isAutoReconnect() } + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenReadyGatedModeWithNullToken_isEligibleForConnection() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns true + every { AuthManager.getToken() } returns null + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.CONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.CONNECTED, snapshot.status) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenReadyNotGatedModeAndNullToken_isNotEligible() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns null + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.DISCONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertFalse(snapshot.isEligibleForConnection) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenAuthExpired_policyReleasesService() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns "some-token" + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.AUTH_EXPIRED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.AUTH_EXPIRED, snapshot.status) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenAutoReconnectDisabledAndDisconnected_policyReleasesService() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns "some-token" + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns false + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.DISCONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.DISCONNECTED, snapshot.status) + assertFalse(snapshot.isAutoReconnect) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt new file mode 100644 index 00000000..fda85801 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt @@ -0,0 +1,253 @@ +package com.m57.hermescontrol.notification + +import com.m57.hermescontrol.data.ws.ConnectionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackgroundConnectionPolicyTest { + @Test + fun testForeground_hasNoServiceDemand() { + val state = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testIneligible_winsOverAllDemand() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = false, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testBackground_optInOff_noPendingReply_noService() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testBackground_optInOff_pendingReply_holdsServiceRepliesOnly() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } + + @Test + fun testBackground_optInOn_noPendingReply_holdsServiceAndPersistentLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } + + @Test + fun testBackground_optInOn_pendingReply_holdsBoth_notificationPrefersWaiting() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } + + @Test + fun testDeparting_holdsServiceBeforeBackgroundCompletes() { + val state = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = true, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + } + + @Test + fun testBackground_noNetwork_whenAutoReconnectEnabled_showsWaitingForNetwork() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.NO_NETWORK, + isAutoReconnect = true, + hasActiveNetwork = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertEquals(BackgroundNotificationState.WaitingForNetwork, decision.notificationState) + } + + @Test + fun testBackground_reconnecting_showsReconnecting() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.RECONNECTING, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertEquals(BackgroundNotificationState.Reconnecting, decision.notificationState) + } + + @Test + fun testAuthExpired_releasesServiceAndLease_showsNone() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.AUTH_EXPIRED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testDisconnected_whenAutoReconnectDisabled_releasesServiceAndLease_evenWithPendingReply() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.DISCONNECTED, + isAutoReconnect = false, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testNoNetwork_whenAutoReconnectDisabled_releasesServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.NO_NETWORK, + isAutoReconnect = false, + hasActiveNetwork = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testConnecting_whenAutoReconnectDisabled_allowsServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTING, + isAutoReconnect = false, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.Connecting, decision.notificationState) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt index 744d1ac9..c287e05f 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt @@ -25,6 +25,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -63,6 +64,7 @@ class NotificationReplyReceiverTest { fun setUp() { upsertCallCount = 0 ActiveSessionHolder.clear() + ReplyNotificationTracker.resetForTest() // Mock Android framework statics (same pattern as HermesWsClientTest) mockkStatic(android.util.Log::class) @@ -128,6 +130,7 @@ class NotificationReplyReceiverTest { @After fun tearDown() { ActiveSessionHolder.clear() + ReplyNotificationTracker.resetForTest() HermesDatabase.setForTest(null) unmockkAll() } @@ -186,6 +189,24 @@ class NotificationReplyReceiverTest { } } + @Test + fun `valid reply clears active reply notification target`() { + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "session-abc", + completionId = "comp-1", + textSnippet = "old reply", + generation = 1L, + ) + assertNotNull(ReplyNotificationTracker.getActiveTarget()) + + givenValidReply("session-abc", "Hello") + receiver.onReceive(mockContext, mockIntent) + Thread.sleep(500) + + assertNull(ReplyNotificationTracker.getActiveTarget()) + } + @Test fun `valid reply finishes pending result after Room save`() { givenValidReply("session-abc", "Hello") diff --git a/app/src/test/java/com/m57/hermescontrol/notification/ReplyNotificationTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/ReplyNotificationTrackerTest.kt new file mode 100644 index 00000000..3219a17d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/ReplyNotificationTrackerTest.kt @@ -0,0 +1,253 @@ +package com.m57.hermescontrol.notification + +import android.app.NotificationManager +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ReplyNotificationTrackerTest { + private lateinit var mockContext: Context + private lateinit var mockNotificationManager: NotificationManager + + @Before + fun setUp() { + mockContext = mockk(relaxed = true) + mockNotificationManager = mockk(relaxed = true) + every { mockContext.getSystemService(Context.NOTIFICATION_SERVICE) } returns mockNotificationManager + ReplyNotificationTracker.resetForTest() + } + + @After + fun tearDown() { + ReplyNotificationTracker.resetForTest() + } + + @Test + fun `onReplyNotificationPosted sets target correctly`() { + val gen = ReplyNotificationTracker.nextGeneration() + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + textSnippet = "Hello world reply", + generation = gen, + ) + + val target = ReplyNotificationTracker.getActiveTarget() + assertEquals("prof-1", target?.scopeId) + assertEquals("sess-123", target?.sessionId) + assertEquals("comp-456", target?.completionId) + assertEquals("Hello world reply", target?.textSnippet) + assertEquals(gen, target?.generation) + } + + @Test + fun `matches verifies scope, session and completionId`() { + val target = + ReplyNotificationTarget( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + generation = 1L, + textSnippet = "Answer text", + ) + + assertTrue(target.matches("prof-1", "sess-123", "comp-456")) + // Scoped target rejects blank or null scope + assertFalse(target.matches("", "sess-123", "comp-456")) + assertFalse(target.matches(null, "sess-123", "comp-456")) + + // Target with blank scope matches unspecified scope + val unscopedTarget = target.copy(scopeId = "") + assertTrue(unscopedTarget.matches("", "sess-123", "comp-456")) + assertTrue(unscopedTarget.matches(null, "sess-123", "comp-456")) + + // Wrong session fails + assertFalse(target.matches("prof-1", "other-sess", "comp-456")) + // Wrong scope fails + assertFalse(target.matches("prof-2", "sess-123", "comp-456")) + // Wrong completionId fails + assertFalse(target.matches("prof-1", "sess-123", "other-comp")) + } + + @Test + fun `matches requires exact completionId match and rejects null or blank`() { + val target = + ReplyNotificationTarget( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + generation = 1L, + textSnippet = "Here is the full summary of the changes", + ) + + assertTrue( + target.matches( + candidateScopeId = "prof-1", + candidateSessionId = "sess-123", + candidateCompletionId = "comp-456", + ), + ) + + assertFalse( + "Missing candidate completionId must not match target", + target.matches( + candidateScopeId = "prof-1", + candidateSessionId = "sess-123", + candidateCompletionId = null, + ), + ) + + assertFalse( + "Different candidate completionId must not match target", + target.matches( + candidateScopeId = "prof-1", + candidateSessionId = "sess-123", + candidateCompletionId = "other-comp", + ), + ) + } + + @Test + fun `onNonReplyNotificationPosted clears target`() { + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + textSnippet = "Reply", + generation = 1L, + ) + assertTrue(ReplyNotificationTracker.getActiveTarget() != null) + + ReplyNotificationTracker.onNonReplyNotificationPosted() + assertNull(ReplyNotificationTracker.getActiveTarget()) + } + + @Test + fun `onMessageVisible cancels notification on match and clears target`() { + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + textSnippet = "Reply text", + generation = 1L, + ) + + val cancelled = + ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + ) + + assertTrue(cancelled) + verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + assertNull(ReplyNotificationTracker.getActiveTarget()) + } + + @Test + fun `onMessageVisible does not cancel if session does not match`() { + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-456", + textSnippet = "Reply text", + generation = 1L, + ) + + val cancelled = + ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = "prof-1", + sessionId = "different-session", + completionId = "comp-456", + ) + + assertFalse(cancelled) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + assertTrue(ReplyNotificationTracker.getActiveTarget() != null) + } + + @Test + fun `cancelReplyNotification refuses to cancel if newer generation was posted`() { + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-newer", + textSnippet = "Newer reply", + generation = 5L, + ) + + val cancelled = + ReplyNotificationTracker.cancelReplyNotification( + context = mockContext, + expectedGeneration = 4L, // older generation + ) + + assertFalse(cancelled) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + } + + @Test + fun `cancelReplyNotification refuses to cancel if active notification is not a reply`() { + ReplyNotificationTracker.setTargetForTest(null) + ReplyNotificationTracker.activeNotificationProvider = { + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_ACTION, + scopeId = "prof-1", + sessionId = "sess-123", + completionId = "comp-1", + textSnippet = "Approval needed", + generation = 1L, + timestamp = 1000L, + ) + } + + val cancelled = + ReplyNotificationTracker.cancelReplyNotification( + context = mockContext, + expectedGeneration = 1L, + ) + + assertFalse(cancelled) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + } + + @Test + fun `cold start recovery restores target from active notification extras`() { + ReplyNotificationTracker.setTargetForTest(null) + ReplyNotificationTracker.activeNotificationProvider = { + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "prof-recovered", + sessionId = "sess-recovered", + completionId = "comp-recovered", + textSnippet = "recovered snippet", + generation = 3L, + timestamp = 1000L, + ) + } + + val cancelled = + ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = "prof-recovered", + sessionId = "sess-recovered", + completionId = "comp-recovered", + ) + + assertTrue(cancelled) + verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/TurnCorrelationTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/TurnCorrelationTrackerTest.kt new file mode 100644 index 00000000..f5f18794 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/TurnCorrelationTrackerTest.kt @@ -0,0 +1,411 @@ +package com.m57.hermescontrol.notification + +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.remote.ApiClient +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * Turn-boundary correlation contract for reply notifications. + * + * The invariant under test: a completion may only be bound to a REST row that + * is provably inside its own turn — strictly above the pre-submit high-watermark + * — and only when exactly one such row matches. Everything else fails closed, + * because a notification that stays active is strictly better than one that is + * dismissed by the wrong duplicate reply. + * + * There is deliberately no clock anywhere in this file: Android notification + * time and Hermes server time come from different machines. + */ +class TurnCorrelationTrackerTest { + private val writeLog = mutableListOf>() + private var storedBoundaries: List = emptyList() + + @Before + fun setUp() { + writeLog.clear() + storedBoundaries = emptyList() + TurnCorrelationTracker.resetForTest() + TurnCorrelationTracker.attachStore(recordingStore()) + } + + @After + fun tearDown() { + TurnCorrelationTracker.resetForTest() + } + + // ── Harness ───────────────────────────────────────────────────────────── + + private fun recordingStore(): TurnBoundaryStore = + object : TurnBoundaryStore { + override fun read(): List = storedBoundaries + + override fun write(boundaries: List) { + storedBoundaries = boundaries + writeLog.add(boundaries) + } + } + + private fun row( + id: Int, + text: String, + role: String = "assistant", + ) = SessionMessage(id = id, role = role, content = JsonPrimitive(text)) + + private fun boundary( + beforeMessageId: Int, + scopeId: String = "default", + sessionId: String = "session", + ) = TurnBoundary( + scopeId = scopeId, + sessionId = sessionId, + beforeMessageId = beforeMessageId, + generation = 1L, + armedAt = 1L, + ) + + /** Resolver over a scripted sequence of REST pages; records how often it fetched. */ + private fun scriptedResolver( + pages: List>, + fetches: MutableList = mutableListOf(), + ): TurnRowResolver { + var index = 0 + return TurnRowResolver( + fetchLatestRows = { + fetches.add(index) + pages.getOrNull(index++) + }, + retryDelaysMs = listOf(0L, 0L, 0L), + ) + } + + private fun resolve( + pages: List>, + completionText: String, + beforeMessageId: Int = 0, + ): Int? = + runBlocking { + scriptedResolver(pages).resolve("session", boundary(beforeMessageId), completionText) + } + + // ── A/B: historical duplicates can never win ───────────────────────────── + + @Test + fun historicalDuplicateBelowBoundaryIsExcluded() { + val rows = listOf(row(5, "Done"), row(11, "Done")) + assertEquals(11, resolve(listOf(rows), "Done", beforeMessageId = 10)) + } + + @Test + fun oldDuplicateVisibleBeforeOwnRowAppearsIsNeverAccepted() { + // Attempt 1 sees only the historical duplicate; the turn's own row has + // not been persisted yet. Resolving on the first attempt would bind the + // notification to the wrong reply. + val fetches = mutableListOf() + val pages = + listOf( + listOf(row(5, "Done")), + listOf(row(5, "Done"), row(11, "Done")), + ) + val resolved = + runBlocking { + scriptedResolver(pages, fetches) + .resolve("session", boundary(beforeMessageId = 10), "Done") + } + assertEquals(11, resolved) + assertEquals(2, fetches.size) + } + + @Test + fun rowPersistedOnlyOnFinalAttemptStillResolves() { + val pages = + listOf( + listOf(row(5, "Done")), + listOf(row(5, "Done")), + listOf(row(5, "Done"), row(11, "Done")), + ) + assertEquals(11, resolve(pages, "Done", beforeMessageId = 10)) + } + + // ── C: ambiguity fails closed ──────────────────────────────────────────── + + @Test + fun twoMatchingRowsAboveBoundaryFailClosedWithoutRetrying() { + val fetches = mutableListOf() + val pages = listOf(listOf(row(11, "Done"), row(12, "Done")), listOf(row(11, "Done"), row(12, "Done"))) + val resolved = + runBlocking { + scriptedResolver(pages, fetches) + .resolve("session", boundary(beforeMessageId = 10), "Done") + } + assertNull("Ambiguous turn must not be guessed", resolved) + assertEquals("Ambiguity is terminal — no pointless retries", 1, fetches.size) + } + + @Test + fun nonMatchingContentAboveBoundaryFailsClosed() { + assertNull(resolve(listOf(listOf(row(11, "Other"))), "Done", beforeMessageId = 10)) + } + + @Test + fun userRowIsNotACandidate() { + assertNull(resolve(listOf(listOf(row(11, "Done", role = "user"))), "Done", beforeMessageId = 10)) + } + + @Test + fun blankCompletionTextNeverResolves() { + assertNull(resolve(listOf(listOf(row(11, "Done"))), " ", beforeMessageId = 10)) + } + + // ── Truncation / MEDIA ─────────────────────────────────────────────────── + + @Test + fun identicalHundredCharacterPrefixStillDisambiguates() { + val prefix = "x".repeat(100) + val rows = + listOf( + row(11, prefix + "A"), + row(12, prefix + "B"), + ) + // The full completion text is compared, never the 100-char preview, so + // the shared prefix cannot make the two rows interchangeable. + assertEquals(12, resolve(listOf(rows), prefix + "B", beforeMessageId = 10)) + } + + @Test + fun mediaReplyResolvesByCanonicalTextAndPath() { + val completion = "Here is the image MEDIA:/opt/hermes/image.png" + val rows = + listOf( + row(11, "Here is another image MEDIA:/opt/hermes/other.png"), + row(12, "Here is the image MEDIA:/opt/hermes/image.png"), + ) + assertEquals(12, resolve(listOf(rows), completion, beforeMessageId = 10)) + } + + @Test + fun mediaOnlyReplyResolvesByPathInsteadOfMatchingAnyAssistantRow() { + val rows = + listOf( + row(11, "something else entirely"), + row(12, "MEDIA:/opt/hermes/image.png"), + ) + assertEquals(12, resolve(listOf(rows), "MEDIA:/opt/hermes/image.png", beforeMessageId = 10)) + } + + @Test + fun duplicateMediaRowsAfterBoundaryFailClosed() { + val rows = + listOf( + row(11, "MEDIA:/opt/hermes/image.png"), + row(12, "MEDIA:/opt/hermes/image.png"), + ) + assertNull(resolve(listOf(rows), "MEDIA:/opt/hermes/image.png", beforeMessageId = 10)) + } + + // ── F: REST failure ────────────────────────────────────────────────────── + + @Test + fun restFailureFailsClosed() { + val resolver = + TurnRowResolver( + fetchLatestRows = { throw IOException("gateway unreachable") }, + retryDelaysMs = listOf(0L, 0L, 0L), + ) + assertNull(runBlocking { resolver.resolve("session", boundary(10), "Done") }) + } + + @Test + fun emptyPagesFailClosed() { + assertNull(resolve(listOf(emptyList(), emptyList()), "Done", beforeMessageId = 10)) + } + + // ── G/H/I: missing, cross-profile and cross-session boundaries ─────────── + + @Test + fun noBoundaryMeansNoLookupAtAll() { + val fetches = mutableListOf() + val resolved = + runBlocking { + correlateCompletedTurnRow( + scopeId = "default", + sessionId = "session", + completionText = "Done", + resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches), + ) + } + assertNull(resolved) + assertTrue("Uncorrelatable turns must not even hit REST", fetches.isEmpty()) + } + + @Test + fun boundaryFromAnotherProfileIsInvisible() { + TurnCorrelationTracker.armBoundary("other-profile", "session", 10) + val fetches = mutableListOf() + val resolved = + runBlocking { + correlateCompletedTurnRow( + scopeId = "default", + sessionId = "session", + completionText = "Done", + resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches), + ) + } + assertNull(resolved) + assertTrue(fetches.isEmpty()) + } + + @Test + fun boundaryFromAnotherSessionIsInvisible() { + TurnCorrelationTracker.armBoundary("default", "other-session", 10) + val fetches = mutableListOf() + val resolved = + runBlocking { + correlateCompletedTurnRow( + scopeId = "default", + sessionId = "session", + completionText = "Done", + resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches), + ) + } + assertNull(resolved) + assertTrue(fetches.isEmpty()) + } + + // ── Boundary lifecycle ─────────────────────────────────────────────────── + + @Test + fun correlateResolvesThenConsumesTheBoundary() { + TurnCorrelationTracker.armBoundary("default", "session", 10) + val pages = listOf(listOf(row(11, "Done"))) + val first = + runBlocking { + correlateCompletedTurnRow("default", "session", "Done", scriptedResolver(pages)) + } + assertEquals(11, first) + assertNull( + "The turn is over — its boundary must be spent", + TurnCorrelationTracker.boundaryFor("default", "session"), + ) + + val second = + runBlocking { + correlateCompletedTurnRow("default", "session", "Done", scriptedResolver(pages)) + } + assertNull("A second completion must not reuse the consumed boundary", second) + } + + @Test + fun armingPersistsAndSurvivesProcessRestart() { + TurnCorrelationTracker.armBoundary("default", "session", 41) + assertEquals(41, storedBoundaries.single().beforeMessageId) + + // Simulate a fresh process: same store content, empty in-memory cache. + TurnCorrelationTracker.resetForTest() + TurnCorrelationTracker.attachStore(recordingStore()) + assertEquals(41, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId) + } + + /** + * The generation counter is in-memory only, so a restart must lift it above + * whatever the restored boundaries already used. Otherwise an old + * completion's clear (generation N) matches the NEW boundary (also N) and the + * stale-clear guard stops protecting the newer turn. + */ + @Test + fun restoredBoundaryGenerationAdvancesTheCounterPastIt() { + storedBoundaries = + listOf( + TurnBoundary( + scopeId = "default", + sessionId = "session", + beforeMessageId = 10, + generation = 37L, + armedAt = System.currentTimeMillis(), + ), + ) + TurnCorrelationTracker.resetForTest() + TurnCorrelationTracker.attachStore(recordingStore()) + assertEquals(37L, TurnCorrelationTracker.boundaryFor("default", "session")?.generation) + + val next = TurnCorrelationTracker.armBoundary("default", "session", 25) + assertNotNull(next) + assertTrue( + "A post-restart boundary must not reuse a recovered generation (got ${next?.generation})", + (next?.generation ?: 0L) > 37L, + ) + + // The old completion, still holding generation 37, must not clear it. + assertFalse(TurnCorrelationTracker.clearBoundary("default", "session", 37L)) + assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId) + } + + @Test + fun reArmingForTheSameSessionReplacesTheBoundary() { + TurnCorrelationTracker.armBoundary("default", "session", 10) + TurnCorrelationTracker.armBoundary("default", "session", 25) + assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId) + assertEquals(1, storedBoundaries.size) + } + + @Test + fun staleGenerationCannotClearANewerBoundary() { + val stale = TurnCorrelationTracker.armBoundary("default", "session", 10) + TurnCorrelationTracker.armBoundary("default", "session", 25) + assertNotNull(stale) + assertFalse(TurnCorrelationTracker.clearBoundary("default", "session", stale?.generation)) + assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId) + } + + @Test + fun blankIdentityOrNegativeBoundaryNeverArms() { + assertNull(TurnCorrelationTracker.armBoundary("", "session", 10)) + assertNull(TurnCorrelationTracker.armBoundary("default", " ", 10)) + assertNull(TurnCorrelationTracker.armBoundary("default", "session", -1)) + assertTrue(writeLog.isEmpty()) + } + + @Test + fun zeroBoundaryIsValidForAnEmptyTranscript() { + // A brand-new session has no rows yet: the next turn's rows are all + // above 0, and there are no historical duplicates to exclude. + assertNotNull(TurnCorrelationTracker.armBoundary("default", "session", 0)) + assertEquals(0, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId) + } + + /** + * The probe is best-effort by contract and must never fail the caller's turn. + * + * A gateway-layer `Error` (uninitialised client, NoClassDefFound) is not an + * `Exception`, so it used to escape the caller's `catch (Exception)`, abort + * the notification-reply send, and leak out of a background scope — which CI + * then reported as `UncaughtExceptionsBeforeTest` in an unrelated later test + * class. Encode that as a regression so it cannot come back. + */ + @Test + fun boundaryProbeSwallowsEvenGatewayLayerErrors() { + mockkObject(ApiClient) + try { + every { ApiClient.hermesApi } throws ExceptionInInitializerError("ApiClient unavailable") + assertFalse( + "A broken gateway layer must not fail the turn", + runBlocking { captureTurnBoundary("default", "session") }, + ) + assertNull(TurnCorrelationTracker.boundaryFor("default", "session")) + } finally { + unmockkObject(ApiClient) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt index 5381e410..b628f560 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -16,11 +16,20 @@ import org.junit.Test * signal "queued" when the user has text ready during a busy turn. */ class ChatInputPolicyTest { + @Test + fun canSend_requiresReadySessionForTextCommandsAndAttachments() { + assertFalse(ChatInputPolicy.canSend("draft", emptyList(), true, false)) + assertFalse(ChatInputPolicy.canSend("/queue draft", emptyList(), true, false)) + assertFalse(ChatInputPolicy.canSend("", listOf(Any()), true, false)) + assertTrue(ChatInputPolicy.canSend("draft", emptyList(), true, true)) + assertTrue(ChatInputPolicy.canSend("", listOf(Any()), true, true)) + } + @Test fun canSend_allowsRegularPromptWhileAgentTyping() { assertTrue( "regular prompt must be sendable while agent is typing", - ChatInputPolicy.canSend("next task", emptyList(), isConnected = true), + ChatInputPolicy.canSend("next task", emptyList(), isConnected = true, isSessionReady = true), ) } @@ -31,7 +40,7 @@ class ChatInputPolicyTest { // text are the only gates, so it must be allowed. assertTrue( "regular prompt must be sendable while an approval is pending", - ChatInputPolicy.canSend("yes, proceed", emptyList(), isConnected = true), + ChatInputPolicy.canSend("yes, proceed", emptyList(), isConnected = true, isSessionReady = true), ) } @@ -39,7 +48,7 @@ class ChatInputPolicyTest { fun canSend_blocksWhenDisconnected() { assertFalse( "no sends while disconnected", - ChatInputPolicy.canSend("next task", emptyList(), isConnected = false), + ChatInputPolicy.canSend("next task", emptyList(), isConnected = false, isSessionReady = true), ) } @@ -47,7 +56,7 @@ class ChatInputPolicyTest { fun canSend_blocksWhenInputEmptyAndNoAttachments() { assertFalse( "empty input with no attachments must not enable send", - ChatInputPolicy.canSend("", emptyList(), isConnected = true), + ChatInputPolicy.canSend("", emptyList(), isConnected = true, isSessionReady = true), ) } @@ -55,7 +64,7 @@ class ChatInputPolicyTest { fun canSend_allowsAttachmentWithEmptyText() { assertTrue( "a pending attachment enables send even with empty text", - ChatInputPolicy.canSend("", listOf("att"), isConnected = true), + ChatInputPolicy.canSend("", listOf("att"), isConnected = true, isSessionReady = true), ) } @@ -89,7 +98,7 @@ class ChatInputPolicyTest { // not regress that by blocking a non-blank slash command. assertTrue( "/stop must remain sendable while typing", - ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true), + ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true, isSessionReady = true), ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt index 57a3e7ae..780e2751 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before @@ -10,6 +11,41 @@ class ChatPersistenceRepositoryTest { private lateinit var dao: FakeChatMessageDao private lateinit var repository: ChatPersistenceRepository + @Test + fun providerIsLazyAndPendingWriteWaitsForDatabase() = + runTest { + val ready = kotlinx.coroutines.CompletableDeferred() + var calls = 0 + val lazyRepository = + ChatPersistenceRepository { + calls++ + ready.await() + dao + } + assertEquals(0, calls) + val pending = + async { + lazyRepository.persistMessage( + ChatMessage(id = "pending", role = MessageRole.USER, content = "saved"), + "s", + ) + } + kotlinx.coroutines.yield() + assertEquals(1, calls) + org.junit.Assert.assertFalse(pending.isCompleted) + ready.complete(Unit) + pending.await() + assertEquals("pending", dao.getMessagesForSession("s").single().id) + } + + @Test + fun providerFailureIsNotReportedAsEmptyHistory() = + runTest { + val failure = IllegalStateException("database unavailable") + val failing = ChatPersistenceRepository { throw failure } + org.junit.Assert.assertSame(failure, runCatching { failing.loadMessages("s") }.exceptionOrNull()) + } + @Before fun setup() { dao = FakeChatMessageDao() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationIntegrationTest.kt new file mode 100644 index 00000000..3baa2d43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationIntegrationTest.kt @@ -0,0 +1,296 @@ +package com.m57.hermescontrol.ui.chat + +import android.app.NotificationManager +import android.content.Context +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.m57.hermescontrol.notification.ChatNotificationService +import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.ui.chat.components.ChatReadObserver +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ChatReadNotificationIntegrationTest { + private lateinit var mockContext: Context + private lateinit var mockNotificationManager: NotificationManager + + @Before + fun setUp() { + mockContext = mockk(relaxed = true) + mockNotificationManager = mockk(relaxed = true) + every { mockContext.getSystemService(Context.NOTIFICATION_SERVICE) } returns mockNotificationManager + ReplyNotificationTracker.resetForTest() + } + + @After + fun tearDown() { + ReplyNotificationTracker.resetForTest() + } + + private fun createMockItem( + key: Any, + offset: Int, + size: Int, + ): LazyListItemInfo { + val item = mockk() + every { item.key } returns key + every { item.offset } returns offset + every { item.size } returns size + return item + } + + private fun createMockLayoutInfo( + items: List, + startOffset: Int = 0, + endOffset: Int = 1000, + ): LazyListLayoutInfo { + val layout = mockk() + every { layout.visibleItemsInfo } returns items + every { layout.viewportStartOffset } returns startOffset + every { layout.viewportEndOffset } returns endOffset + return layout + } + + @Test + fun `end-to-end flow - visible target message cancels notification`() { + val scopeId = "profile-main" + val sessionId = "session-42" + val completionId = "completion-abc" + val textSnippet = "Here is the result of your task" + + val generation = ReplyNotificationTracker.nextGeneration() + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = scopeId, + sessionId = sessionId, + completionId = completionId, + textSnippet = textSnippet, + generation = generation, + ) + + val targetMessage = + ChatMessage( + id = "msg-target", + role = MessageRole.ASSISTANT, + content = textSnippet, + completionId = completionId, + ) + + val layout = + createMockLayoutInfo( + listOf( + createMockItem("prose-msg-target", offset = 200, size = 150), + ), + ) + + val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(targetMessage)) + var wasCancelled = false + for (msg in visibleMessages) { + if (ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = scopeId, + sessionId = sessionId, + completionId = msg.completionId, + ) + ) { + wasCancelled = true + } + } + + assertTrue(wasCancelled) + verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun `scrolled above target - notification stays until scrolled to target`() { + val scopeId = "profile-main" + val sessionId = "session-42" + val completionId = "completion-abc" + val textSnippet = "Newest assistant reply" + + val generation = ReplyNotificationTracker.nextGeneration() + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = scopeId, + sessionId = sessionId, + completionId = completionId, + textSnippet = textSnippet, + generation = generation, + ) + + val olderMessage = + ChatMessage( + id = "msg-older", + role = MessageRole.ASSISTANT, + content = "Old assistant message", + completionId = "comp-old", + ) + val targetMessage = + ChatMessage( + id = "msg-target", + role = MessageRole.ASSISTANT, + content = textSnippet, + completionId = completionId, + ) + val allMessages = listOf(olderMessage, targetMessage) + + // Viewport 0..1000: user is at the top, only older message is visible; target is at offset 1500 (below viewport) + val scrolledUpLayout = + createMockLayoutInfo( + listOf( + createMockItem("prose-msg-older", offset = 100, size = 200), + createMockItem("prose-msg-target", offset = 1500, size = 300), + ), + startOffset = 0, + endOffset = 1000, + ) + + val visibleWhenScrolledUp = ChatReadObserver.findVisibleAssistantMessages(scrolledUpLayout, allMessages) + var wasCancelledWhenScrolledUp = false + for (msg in visibleWhenScrolledUp) { + if (ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = scopeId, + sessionId = sessionId, + completionId = msg.completionId, + ) + ) { + wasCancelledWhenScrolledUp = true + } + } + + assertFalse(wasCancelledWhenScrolledUp) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + + // Now user scrolls down: target message enters viewport (offset 400, size 300) + val scrolledDownLayout = + createMockLayoutInfo( + listOf( + createMockItem("prose-msg-target", offset = 400, size = 300), + ), + startOffset = 0, + endOffset = 1000, + ) + + val visibleWhenScrolledDown = ChatReadObserver.findVisibleAssistantMessages(scrolledDownLayout, allMessages) + var wasCancelledWhenScrolledDown = false + for (msg in visibleWhenScrolledDown) { + if (ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = scopeId, + sessionId = sessionId, + completionId = msg.completionId, + ) + ) { + wasCancelledWhenScrolledDown = true + } + } + + assertTrue(wasCancelledWhenScrolledDown) + verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun `viewing a different session does not dismiss notification`() { + val scopeId = "profile-main" + val sessionIdWithNotif = "session-notif" + val completionId = "completion-abc" + val textSnippet = "Important reply" + + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = scopeId, + sessionId = sessionIdWithNotif, + completionId = completionId, + textSnippet = textSnippet, + generation = 1L, + ) + + val messageInOtherSession = + ChatMessage( + id = "msg-other", + role = MessageRole.ASSISTANT, + content = "Some other reply", + completionId = "other-comp", + ) + + val layout = + createMockLayoutInfo( + listOf( + createMockItem("prose-msg-other", offset = 200, size = 150), + ), + ) + + val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(messageInOtherSession)) + var wasCancelled = false + for (msg in visibleMessages) { + if (ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = scopeId, + sessionId = "different-session-id", + completionId = msg.completionId, + ) + ) { + wasCancelled = true + } + } + + assertFalse(wasCancelled) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + } + + @Test + fun `action notification replacement prevents dismissal by message read`() { + val scopeId = "profile-main" + val sessionId = "session-42" + val completionId = "completion-abc" + val textSnippet = "Reply before action" + + // 1. Reply notification posted + ReplyNotificationTracker.onReplyNotificationPosted( + scopeId = scopeId, + sessionId = sessionId, + completionId = completionId, + textSnippet = textSnippet, + generation = 1L, + ) + + // 2. Later, an approval request arrives and replaces notification ID 2 + ReplyNotificationTracker.onNonReplyNotificationPosted() + + // 3. User opens chat and views the previous reply message + val targetMessage = + ChatMessage( + id = "msg-target", + role = MessageRole.ASSISTANT, + content = textSnippet, + completionId = completionId, + ) + val layout = + createMockLayoutInfo( + listOf( + createMockItem("prose-msg-target", offset = 200, size = 150), + ), + ) + + val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(targetMessage)) + var wasCancelled = false + for (msg in visibleMessages) { + if (ReplyNotificationTracker.onMessageVisible( + context = mockContext, + scopeId = scopeId, + sessionId = sessionId, + completionId = msg.completionId, + ) + ) { + wasCancelled = true + } + } + + assertFalse("Action notification must NOT be dismissed by reading chat message", wasCancelled) + verify(inverse = true) { mockNotificationManager.cancel(any()) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index 604362b0..daf6f132 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -93,6 +93,7 @@ class ChatUpdateCommandTest { every { AuthManager.isUserMessageTokensEnabled() } returns true every { AuthManager.isAssistantMessageTokensEnabled() } returns true every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index b6846926..7c072688 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.PaginationInfo +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.GatewayFile import com.m57.hermescontrol.data.remote.GatewayFileClient @@ -21,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.* @@ -52,6 +56,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -171,6 +176,7 @@ class ChatViewModelTest { every { AuthManager.isUserMessageTokensEnabled() } returns true every { AuthManager.isAssistantMessageTokensEnabled() } returns true every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null @@ -2695,6 +2701,138 @@ class ChatViewModelTest { // ── Session resume recovery (desktop parity: warm cache + bounded retry) ── /** Override the send stub to capture (method → id) pairs. */ + @Test + fun testSendReadiness_restBeforeResume_preservesDraftUntilAck() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + assertTrue(viewModel.uiState.value.isSessionReady) + val captured = captureSends() + viewModel.switchSession("session-456") + advanceUntilIdle() + val before = viewModel.uiState.value + assertFalse(before.isSessionReady) + assertFalse(viewModel.sendMessage("keep this draft")) + assertEquals(before.messages, viewModel.uiState.value.messages) + assertEquals(before.pendingAttachments, viewModel.uiState.value.pendingAttachments) + assertFalse(viewModel.uiState.value.isAgentTyping) + val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to "runtime-456"))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + assertTrue(viewModel.sendMessage("keep this draft")) + advanceUntilIdle() + assertEquals( + 1, + viewModel.uiState.value.messages + .count { it.content == "keep this draft" }, + ) + } + + @Test + fun testSendReadiness_resumeBeforeRest_waitsForHydration() = + runTest { + stubSession456Rests(success = true) + val response = + CompletableDeferred>() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + viewModel.switchSession("session-456") + runCurrent() + val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to "runtime-456"))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + assertFalse(viewModel.sendMessage("/queue draft")) + response.complete( + retrofit2.Response.success( + com.m57.hermescontrol.data.model + .SessionMessagesResponse(messages = emptyList()), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + } + + @Test + fun testSendReadiness_reconnectRejectsOldAckAndRequiresFreshHydration() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + viewModel.switchSession("session-456") + advanceUntilIdle() + val oldId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockConnectionStatus.value = ConnectionStatus.RECONNECTING + runCurrent() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.RpcResult(oldId, mapOf("session_id" to "stale-runtime"))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + assertFalse(viewModel.sendMessage("draft")) + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isSessionReady) + val freshId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(freshId, mapOf("session_id" to "fresh-runtime"))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + viewModel.retryResumeSession() + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + } + + @Test + fun testSendReadiness_malformedAckNeverEnablesSend() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + viewModel.switchSession("session-456") + runCurrent() + val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to ""))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + assertFalse(viewModel.sendMessage("draft")) + assertTrue(viewModel.uiState.value.isResumeRetrying) + } + + @Test + fun testSendReadiness_abandonedCreateCannotAcceptDraft() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.RECONNECTING + runCurrent() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + runCurrent() + assertFalse(viewModel.sendMessage("retain me")) + assertFalse(viewModel.uiState.value.isAgentTyping) + } + + @Test + fun testSendReadiness_pendingCreateAppliesBackpressure() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + assertTrue(viewModel.sendMessage("first")) + assertFalse(viewModel.sendMessage("second")) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "second" }, + ) + } + private fun captureSends(): MutableList> { val captured = mutableListOf>() every { HermesWsClient.send(any(), any(), any()) } answers { @@ -4203,6 +4341,7 @@ class ChatViewModelTest { assertTrue(showUserMessageTokens) assertTrue(showAssistantMessageTokens) assertTrue(showTokensPerSecond) + assertFalse(showModelProvider) } // When settings change after construction and refreshSettings() is re-invoked, @@ -4214,6 +4353,7 @@ class ChatViewModelTest { every { AuthManager.isUserMessageTokensEnabled() } returns false every { AuthManager.isAssistantMessageTokensEnabled() } returns false every { AuthManager.isTokensPerSecondEnabled() } returns false + every { AuthManager.isModelProviderShown() } returns true viewModel.refreshSettings() advanceUntilIdle() @@ -4225,6 +4365,7 @@ class ChatViewModelTest { assertFalse(state.showUserMessageTokens) assertFalse(state.showAssistantMessageTokens) assertFalse(state.showTokensPerSecond) + assertTrue(state.showModelProvider) } @Test @@ -5454,4 +5595,234 @@ class ChatViewModelTest { io.mockk.verify { AuthManager.setLastOpenedSessionId("session-other-456") } } + + // ── Turn-boundary capture ordering (reply-notification correlation) ─────── + + /** + * Turn boundaries are keyed by the active profile, so these tests need one. + * Scoped per test rather than added to setUp: a non-blank profile is also + * appended to `session.resume` params, which the older resume/switch tests + * assert exactly. + */ + private fun stubActiveProfile() { + every { AuthManager.activeProfileId } returns MutableStateFlow("default") + } + + /** + * The REST high-watermark must be read BEFORE prompt.submit leaves the + * device. Capturing it afterwards lets a fast turn persist its assistant row + * first, and a lower bound that already contains the reply can never exclude + * a historical duplicate. + */ + @Test + fun sendMessage_readsTurnBoundaryBeforeSubmittingPrompt() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val events = mutableListOf() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers { + // Only the boundary probe asks for a single newest row. + if (arg(1) == 1 && arg(3) == "latest") events += "boundary" + retrofit2.Response.success( + SessionMessagesResponse( + messages = listOf(SessionMessage(id = 41, role = "user", content = JsonPrimitive("hi"))), + pagination = PaginationInfo(limit = 1, offset = 0, order = "latest", returned = 1), + ), + ) + } + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + events += "submit" + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("hello there") + advanceUntilIdle() + + assertEquals(listOf("boundary", "submit"), events) + assertEquals(41, TurnCorrelationTracker.boundaryFor("default", sessionId)?.beforeMessageId) + } + + @Test + fun sendMessage_stillSubmitsWhenBoundaryReadFails() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } returns + retrofit2.Response.error(500, "boom".toResponseBody()) + var submits = 0 + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + submits++ + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("hello there") + advanceUntilIdle() + + assertEquals("REST health must never block sending chat", 1, submits) + assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId)) + } + + /** + * A gateway that ignores `order=latest` answers a `limit=1` probe with the + * OLDEST row; trusting that number would arm a boundary that is far too low + * and let historical duplicates win. Without the pagination proof the turn is + * left uncorrelatable, and the prompt still goes out. + */ + @Test + fun sendMessage_leavesTurnUncorrelatableWhenGatewayCannotConfirmOrder() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers { + retrofit2.Response.success( + SessionMessagesResponse( + messages = listOf(SessionMessage(id = 1, role = "user", content = JsonPrimitive("oldest"))), + ), + ) + } + var submits = 0 + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + submits++ + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("hello there") + advanceUntilIdle() + + assertEquals(1, submits) + assertNull( + "Unconfirmed row order must not produce a boundary", + TurnCorrelationTracker.boundaryFor("default", sessionId), + ) + } + + /** + * `AuthManager.activeProfileId` is null until a server profile is explicitly + * selected, and the rest of AuthManager treats that as + * [AuthManager.DEFAULT_PROFILE_ID]. A raw `.orEmpty()` made the whole feature + * a no-op on a normal install: no boundary armed, so no reply notification + * could ever be REST-auto-dismissed. + */ + @Test + fun sendMessage_nullActiveProfileArmsBoundaryUnderTheDefaultScope() = + runTest { + TurnCorrelationTracker.resetForTest() + every { AuthManager.activeProfileId } returns MutableStateFlow(null) + val (viewModel, sessionId) = createViewModelWithSession() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers { + retrofit2.Response.success( + SessionMessagesResponse( + messages = listOf(SessionMessage(id = 41, role = "user", content = JsonPrimitive("hi"))), + pagination = PaginationInfo(limit = 1, offset = 0, order = "latest", returned = 1), + ), + ) + } + var submits = 0 + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + submits++ + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("hello there") + advanceUntilIdle() + + assertEquals(1, submits) + assertEquals( + "A default-profile install must still be correlatable", + 41, + TurnCorrelationTracker.boundaryFor(AuthManager.DEFAULT_PROFILE_ID, sessionId)?.beforeMessageId, + ) + } + + /** + * `pagination.order` is the only proof the gateway honoured `order=latest`. + * A gateway that reports `oldest` answered the `limit=1` probe with the + * OLDEST row, and that as a lower bound would sit below the whole transcript + * — exactly what lets a historical duplicate win. + */ + @Test + fun sendMessage_leavesTurnUncorrelatableWhenGatewayReportsOldestOrder() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers { + retrofit2.Response.success( + SessionMessagesResponse( + messages = listOf(SessionMessage(id = 1, role = "user", content = JsonPrimitive("oldest"))), + pagination = PaginationInfo(limit = 1, offset = 0, order = "oldest", returned = 1), + ), + ) + } + var submits = 0 + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + submits++ + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("hello there") + advanceUntilIdle() + + assertEquals(1, submits) + assertNull( + "An unproven row order must not produce a boundary", + TurnCorrelationTracker.boundaryFor("default", sessionId), + ) + } + + /** + * A queued prompt runs behind a turn that is already in flight, so no clean + * lower bound exists for it — it must not borrow the running turn's boundary. + */ + @Test + fun queuedPromptNeverArmsATurnBoundary() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/queue do the thing") + advanceUntilIdle() + + io.mockk.coVerify(exactly = 0) { + api.getSessionMessages(any(), any(), any(), any(), any(), any()) + } + assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId)) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 7faa6810..7b5814ae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -450,6 +450,25 @@ class ChatWsEventReducerTest { ) } + @Test + fun testMessageComplete_setsCompletionIdOnAssistantMessage() { + val event = + WsEvent.MessageComplete( + text = "hello from assistant", + sessionId = "session-1", + completionId = "comp-123", + ) + val result = + ChatWsEventReducer.reduce( + state = ChatUiState(), + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + val msg = result.state.messages.single() + assertEquals("comp-123", msg.completionId) + } + @Test fun testMessageStart_prunesCompletedSubagents() { val completedSubagent = @@ -509,6 +528,103 @@ class ChatWsEventReducerTest { assertTrue(result.state.todos[1].isInProgress) } + @Test + fun testToolLifecycle_sameNameUsesToolIdForProgressAndCompletion() { + val state = ChatUiState(currentSessionId = "session-1") + val first = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.ToolStart( + name = "process_manage", + data = mapOf("tool_id" to "call-1", "action" to "wait"), + sessionId = "session-1", + ), + "session-1", + ) + val second = + ChatWsEventReducer.reduce( + first.state, + first.streamingState, + WsEvent.ToolStart( + name = "process_manage", + data = mapOf("tool_id" to "call-2", "action" to "wait"), + sessionId = "session-1", + ), + "session-1", + ) + + val progress = + ChatWsEventReducer.reduce( + second.state, + second.streamingState, + WsEvent.ToolProgress( + name = "process_manage", + preview = "call-1 is still waiting", + sessionId = "session-1", + toolId = "call-1", + ), + "session-1", + ) + assertEquals("call-1 is still waiting", progress.state.messages[0].progressPreview) + assertNull(progress.state.messages[1].progressPreview) + + val completed = + ChatWsEventReducer.reduce( + progress.state, + progress.streamingState, + WsEvent.ToolComplete( + name = "process_manage", + data = mapOf("tool_id" to "call-1", "output" to "finished"), + sessionId = "session-1", + ), + "session-1", + ) + assertEquals(ToolStatus.COMPLETED, completed.state.messages[0].toolStatus) + assertEquals(ToolStatus.RUNNING, completed.state.messages[1].toolStatus) + } + + @Test + fun testToolOutputRisk_sameNameUsesToolId() { + val state = ChatUiState(currentSessionId = "session-1") + val first = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.ToolStart("terminal", mapOf("tool_id" to "call-1"), "session-1"), + "session-1", + ) + val second = + ChatWsEventReducer.reduce( + first.state, + first.streamingState, + WsEvent.ToolStart("terminal", mapOf("tool_id" to "call-2"), "session-1"), + "session-1", + ) + + val result = + ChatWsEventReducer.reduce( + second.state, + second.streamingState, + WsEvent.ToolOutputRisk( + toolId = "call-1", + name = "terminal", + risk = "high", + findings = listOf("secret"), + sessionId = "session-1", + ), + "session-1", + ) + + assertEquals( + "high", + result.state.messages[0] + .toolOutputRiskData + ?.risk, + ) + assertNull(result.state.messages[1].toolOutputRiskData) + } + @Test fun testHydrateTodosFromMessages_parsesStoredToolMessage() { val todoMessage = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt index c0a90495..cd3df878 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt @@ -5,31 +5,37 @@ import org.junit.Test /** * Unit tests for [composerModelLabel] — the label shown in the composer's - * model/reasoning pill. Only the "custom:" provider marker is dropped; the - * provider stays so the same model from different providers never collides. + * model/reasoning pill. + * By default ([showProvider] = false), only the model name is shown. + * When [showProvider] = true, the provider is kept (with "custom:" stripped). */ class ComposerToolbarTest { @Test - fun composerModelLabel_keepsProviderToDisambiguateSameModel() { - assertEquals("openai/gpt-5.5", composerModelLabel("openai/gpt-5.5")) - assertEquals("copilot/gpt-5.5", composerModelLabel("copilot/gpt-5.5")) - } - - @Test - fun composerModelLabel_dropsOnlyTheCustomMarker() { - assertEquals("my-provider/glm-5.3", composerModelLabel("custom:my-provider/glm-5.3")) - assertEquals("openrouter/openai/gpt-5.5", composerModelLabel("openrouter/openai/gpt-5.5")) - } - - @Test - fun composerModelLabel_leavesModelTagsAndPlainIdsAlone() { - assertEquals("ollama/llama3:8b", composerModelLabel("ollama/llama3:8b")) + fun composerModelLabel_defaultsToModelOnly() { + assertEquals("gpt-5.5", composerModelLabel("openai/gpt-5.5")) + assertEquals("gpt-5.5", composerModelLabel("copilot/gpt-5.5")) + assertEquals("glm-5.3", composerModelLabel("custom:my-provider/glm-5.3")) + assertEquals("gpt-5.5", composerModelLabel("openrouter/openai/gpt-5.5")) + assertEquals("gpt-5", composerModelLabel("openrouter/openai/gpt-5")) + assertEquals("llama3:8b", composerModelLabel("ollama/llama3:8b")) assertEquals("llama3:8b", composerModelLabel("llama3:8b")) assertEquals("gpt-5", composerModelLabel("gpt-5")) + assertEquals("custom:/glm-5.3", composerModelLabel("custom:/glm-5.3")) + assertEquals("openai/", composerModelLabel("openai/")) + assertEquals("/gpt-5", composerModelLabel("/gpt-5")) } @Test - fun composerModelLabel_bareCustomMarker_fallsBackToFullId() { - assertEquals("custom:/glm-5.3", composerModelLabel("custom:/glm-5.3")) + fun composerModelLabel_keepsProviderWhenEnabled() { + assertEquals("openai/gpt-5.5", composerModelLabel("openai/gpt-5.5", showProvider = true)) + assertEquals("copilot/gpt-5.5", composerModelLabel("copilot/gpt-5.5", showProvider = true)) + assertEquals("my-provider/glm-5.3", composerModelLabel("custom:my-provider/glm-5.3", showProvider = true)) + assertEquals("openrouter/openai/gpt-5.5", composerModelLabel("openrouter/openai/gpt-5.5", showProvider = true)) + assertEquals("ollama/llama3:8b", composerModelLabel("ollama/llama3:8b", showProvider = true)) + assertEquals("llama3:8b", composerModelLabel("llama3:8b", showProvider = true)) + assertEquals("gpt-5", composerModelLabel("gpt-5", showProvider = true)) + assertEquals("custom:/glm-5.3", composerModelLabel("custom:/glm-5.3", showProvider = true)) + assertEquals("openai/", composerModelLabel("openai/", showProvider = true)) + assertEquals("/gpt-5", composerModelLabel("/gpt-5", showProvider = true)) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt new file mode 100644 index 00000000..60ff014b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt @@ -0,0 +1,599 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.GatewayFileClient +import com.m57.hermescontrol.data.remote.MediaDataSourceProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.MessageDigest +import java.util.Base64 + +class MediaExportStreamingTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val context = mockk(relaxed = true) + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + MediaDataSourceProvider.testClient = + okhttp3.OkHttpClient + .Builder() + .addInterceptor( + MediaDataSourceProvider.SameOriginAuthInterceptor( + baseUrlProvider = { mockWebServer.url("/").toString() }, + tokenProvider = { "test-token" }, + isGatedModeProvider = { false }, + ), + ).build() + } + + @After + fun tearDown() { + MediaDataSourceProvider.testClient = null + mockWebServer.shutdown() + } + + // ── 1. API Level Strategy Tests ───────────────────────────────────────── + + @Test + fun `mediaSaveStrategy resolves correctly for API 26-28 vs 29+`() { + assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(26)) + assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(27)) + assertEquals(MediaSaveStrategy.CREATE_DOCUMENT, mediaSaveStrategy(28)) + assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(29)) + assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(30)) + assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(34)) + assertEquals(MediaSaveStrategy.MEDIA_STORE, mediaSaveStrategy(37)) + } + + // ── 2. MIME Normalization Tests ───────────────────────────────────────── + + @Test + fun `normalizeFrameworkMime strips parameters and handles fallbacks`() { + assertEquals("audio/ogg", normalizeFrameworkMime("audio/ogg; codecs=opus")) + assertEquals("video/mp4", normalizeFrameworkMime("video/mp4")) + assertEquals("audio/mpeg", normalizeFrameworkMime(" audio/mpeg ")) + assertEquals("application/octet-stream", normalizeFrameworkMime(null)) + assertEquals("application/octet-stream", normalizeFrameworkMime("")) + assertEquals("application/octet-stream", normalizeFrameworkMime(" ")) + } + + // ── 3. Filename Sanitization Tests ────────────────────────────────────── + + @Test + fun `resolveDisplayName cleans traversal and respects extensions`() { + assertEquals( + "video.mp4", + MediaExportHelper.resolveDisplayName("../../evil/video.mp4", "video/mp4", "https://x/"), + ) + assertEquals( + "recording.wav", + MediaExportHelper.resolveDisplayName("C:\\Users\\x\\recording.wav", "audio/wav", "https://x/"), + ) + assertEquals("clip.mp4", MediaExportHelper.resolveDisplayName("clip", "video/mp4", "https://x/")) + assertEquals("recording.mp3", MediaExportHelper.resolveDisplayName("recording", "audio/mpeg", "https://x/")) + assertEquals("song.m4a", MediaExportHelper.resolveDisplayName("song.m4a", "audio/mp4", "https://x/")) + assertEquals("hermes-media.wav", MediaExportHelper.resolveDisplayName("", "audio/wav", "")) + } + + // ── 4. Local File & Directory Source Validation ───────────────────────── + + @Test + fun `useStream streams local file without whole-media buffering`() = + runTest { + val file = tempFolder.newFile("sample.wav") + file.writeText("RIFF....WAVEfmt ") + + var readString = "" + MediaStreamResolver.useStream(context, file.absolutePath, "audio/wav") { stream, info -> + assertEquals("audio/wav", info.mimeType) + assertEquals("wav", info.extension) + assertEquals(file.length(), info.length) + readString = stream.bufferedReader().readText() + } + assertEquals("RIFF....WAVEfmt ", readString) + } + + @Test + fun `useStream rejects directories cleanly for absolute and file URI sources`() = + runTest { + val dir = tempFolder.newFolder("test-dir") + try { + MediaStreamResolver.useStream(context, dir.absolutePath, "audio/wav") { _, _ -> } + fail("Expected FileNotFoundException for directory path") + } catch (e: FileNotFoundException) { + assertTrue(e.message?.contains("Not a valid file") == true) + } + + try { + MediaStreamResolver.useStream(context, dir.toURI().toString(), "audio/wav") { _, _ -> } + fail("Expected FileNotFoundException for directory file URI") + } catch (e: FileNotFoundException) { + assertTrue(e.message?.contains("Not a valid file") == true) + } + } + + // ── 5. Data URL Streaming Tests ───────────────────────────────────────── + + @Test + fun `useStream decodes data URL on the fly without substring duplication`() = + runTest { + val expected = "streaming-test-content-audio-without-substring-allocations" + val encoded = Base64.getEncoder().encodeToString(expected.toByteArray()) + val dataUrl = "data:audio/mp3;base64, ${encoded.take(6)} \r\n ${encoded.drop(6)} " + + var result = "" + MediaStreamResolver.useStream(context, dataUrl, "audio/mp3") { stream, info -> + assertEquals("audio/mp3", info.mimeType) + assertEquals("mp3", info.extension) + assertNull(info.length) + result = stream.bufferedReader().readText() + } + assertEquals(expected, result) + } + + // ── 6. MockWebServer HTTP Streaming Tests ─────────────────────────────── + + @Test + fun `useStream streams large HTTP response with verified digest and length`() = + runTest { + val chunkSize = 64 * 1024 + val chunkCount = 4 // 256 KB payload + val expectedDigest = MessageDigest.getInstance("SHA-256") + + val buffer = okio.Buffer() + val chunkPattern = ByteArray(chunkSize) { (it % 128).toByte() } + repeat(chunkCount) { + buffer.write(chunkPattern) + expectedDigest.update(chunkPattern) + } + val expectedSha = expectedDigest.digest().joinToString("") { "%02x".format(it) } + + mockWebServer.enqueue( + MockResponse() + .setHeader("Content-Type", "video/mp4; codecs=avc1") + .setBody(buffer), + ) + + val url = mockWebServer.url("/media/video.mp4").toString() + val actualDigest = MessageDigest.getInstance("SHA-256") + val copyBuffer = ByteArray(16 * 1024) + + MediaStreamResolver.useStream(context, url, "video/mp4") { stream, info -> + assertEquals("video/mp4", info.mimeType) + assertEquals("mp4", info.extension) + assertEquals((chunkSize * chunkCount).toLong(), info.length) + + while (true) { + val read = stream.read(copyBuffer) + if (read < 0) break + actualDigest.update(copyBuffer, 0, read) + } + } + + val actualSha = actualDigest.digest().joinToString("") { "%02x".format(it) } + assertEquals(expectedSha, actualSha) + } + + @Test + fun `useStream handles chunked HTTP transfer with null length`() = + runTest { + val content = "chunked-audio-data-payload" + mockWebServer.enqueue( + MockResponse() + .setHeader("Content-Type", "audio/mpeg") + .setChunkedBody(content, 4), + ) + + val url = mockWebServer.url("/media/stream").toString() + var result = "" + MediaStreamResolver.useStream(context, url, "audio/mpeg") { stream, info -> + assertEquals("audio/mpeg", info.mimeType) + assertNull(info.length) + result = stream.bufferedReader().readText() + } + assertEquals(content, result) + } + + @Test + fun `useStream falls back from application octet-stream to provided fallback or URL inference`() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setHeader("Content-Type", "application/octet-stream") + .setBody("payload"), + ) + val url1 = mockWebServer.url("/file/track").toString() + MediaStreamResolver.useStream(context, url1, fallbackMime = "audio/ogg") { _, info -> + assertEquals("audio/ogg", info.mimeType) + assertEquals("ogg", info.extension) + } + + mockWebServer.enqueue( + MockResponse() + .setHeader("Content-Type", "application/octet-stream") + .setBody("payload"), + ) + val url2 = mockWebServer.url("/downloads/song.mp3").toString() + MediaStreamResolver.useStream(context, url2, fallbackMime = "application/octet-stream") { _, info -> + assertEquals("audio/mpeg", info.mimeType) + assertEquals("mp3", info.extension) + } + } + + @Test + fun `useStream throws IOException and closes resources on non-success HTTP status`() = + runTest { + mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("Not Found")) + val url = mockWebServer.url("/missing.mp4").toString() + + try { + MediaStreamResolver.useStream(context, url, "video/mp4") { _, _ -> } + fail("Expected IOException on 404") + } catch (e: IOException) { + assertTrue(e.message?.contains("HTTP 404") == true) + } + } + + // ── 7. Real Copy Cancellation Tests ───────────────────────────────────── + + @Test + fun `copyChunked cancels during copy and rethrows CancellationException`() = + runTest { + var chunksRead = 0 + var job: kotlinx.coroutines.Job? = null + val slowInputStream = + object : InputStream() { + override fun read(): Int = 42 + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + chunksRead++ + if (chunksRead == 3) { + job?.cancel() + } + val toRead = minOf(len, 1024) + b.fill(1, off, off + toRead) + return toRead + } + } + + val outStream = ByteArrayOutputStream() + var caughtCancellation: CancellationException? = null + + job = + launch { + try { + GatewayFileClient.copyChunked(slowInputStream, outStream) + } catch (e: CancellationException) { + caughtCancellation = e + throw e + } + } + + job.join() + + assertNotNull(caughtCancellation) + assertTrue(outStream.size() > 0) + } + + // ── 8. Finalization & Cleanup Failure Tests ────────────────────────────── + + @Test + fun `saveMediaToUri cleans up partial target if stream close throws`() = + runTest { + val sourceFile = tempFolder.newFile("source.mp3").also { it.writeText("sample-data") } + val targetUri = mockk(relaxed = true) + + val throwingStream = + object : OutputStream() { + override fun write(b: Int) {} + + override fun close(): Unit = throw IOException("Disk flush failure on close") + } + + val mockResolver = mockk(relaxed = true) + every { mockResolver.openOutputStream(targetUri) } returns throwingStream + every { context.contentResolver } returns mockResolver + + try { + MediaExportHelper.saveMediaToUri(context, sourceFile.absolutePath, targetUri, "audio/mpeg") + fail("Expected IOException") + } catch (_: IOException) { + verify { mockResolver.delete(targetUri, null, null) } + } + } + + @Test + fun `saveMediaToDownloads deletes item if MediaStore update returns zero`() = + runTest { + val mockValues = mockk(relaxed = true) + val sourceFile = tempFolder.newFile("track.wav").also { it.writeText("riff-data") } + val insertedUri = mockk(relaxed = true) + + val mockResolver = mockk(relaxed = true) + every { mockResolver.insert(any(), any()) } returns insertedUri + every { mockResolver.openOutputStream(insertedUri) } returns ByteArrayOutputStream() + // Simulate update failure (row could not be published) + every { mockResolver.update(insertedUri, any(), null, null) } returns 0 + every { context.contentResolver } returns mockResolver + + try { + MediaExportHelper.saveMediaToDownloads( + context = context, + uri = sourceFile.absolutePath, + fallbackMime = "audio/wav", + currentSdk = 29, + valuesFactory = { _, _, _ -> mockValues }, + ) + fail("Expected IOException on publication failure") + } catch (_: IOException) { + verify { mockResolver.delete(insertedUri, null, null) } + } + } + + @Test + fun `saveMediaToDownloads throws UnsupportedOperationException on API below 29`() = + runTest { + val sourceFile = tempFolder.newFile("track28.wav").also { it.writeText("riff-data") } + try { + MediaExportHelper.saveMediaToDownloads( + context = context, + uri = sourceFile.absolutePath, + fallbackMime = "audio/wav", + currentSdk = 28, + ) + fail("Expected UnsupportedOperationException on API 28") + } catch (e: UnsupportedOperationException) { + assertTrue(e.message?.contains("API 29+") == true) + } + } + + // ── 9. Share Cache Collisions & TTL Sweep Tests ────────────────────────── + + @Test + fun `shareMedia generates collision-proof distinct paths for identical filenames`() = + runTest { + val cacheDir = tempFolder.newFolder("cache") + every { context.cacheDir } returns cacheDir + every { context.packageName } returns "com.m57.hermescontrol" + + val sourceFile = tempFolder.newFile("song.mp3").also { it.writeText("audio-data") } + val fakeUri = mockk(relaxed = true) + val fakeIntent = mockk(relaxed = true) + + // First share + val intent1 = + MediaExportHelper.shareMedia( + context = context, + uri = sourceFile.absolutePath, + fallbackMime = "audio/mpeg", + displayName = "song.mp3", + fileUriProvider = { _, _ -> fakeUri }, + intentFactory = { _, _ -> fakeIntent }, + ) + assertNotNull(intent1) + val sharedDir = File(cacheDir, "shared_media") + val sessionDirs = sharedDir.listFiles { f -> f.isDirectory } ?: emptyArray() + assertEquals(1, sessionDirs.size) + val file1 = File(sessionDirs[0], "song.mp3") + assertTrue(file1.exists()) + + // Second share of identical filename + val intent2 = + MediaExportHelper.shareMedia( + context = context, + uri = sourceFile.absolutePath, + fallbackMime = "audio/mpeg", + displayName = "song.mp3", + fileUriProvider = { _, _ -> fakeUri }, + intentFactory = { _, _ -> fakeIntent }, + ) + assertNotNull(intent2) + val sessionDirsAfter = sharedDir.listFiles { f -> f.isDirectory } ?: emptyArray() + assertEquals(2, sessionDirsAfter.size) + assertNotEquals(sessionDirsAfter[0].absolutePath, sessionDirsAfter[1].absolutePath) + } + + @Test + fun `sweepOldShareFiles deletes expired sessions but preserves fresh ones`() { + val sharedDir = tempFolder.newFolder("shared_media") + val now = 100_000_000L + val ttl = 24 * 60 * 60 * 1000L + + val oldDir = File(sharedDir, "old-session").also { it.mkdirs() } + File(oldDir, "old.mp4").writeText("old") + oldDir.setLastModified(now - ttl - 5000L) + + val freshDir = File(sharedDir, "fresh-session").also { it.mkdirs() } + File(freshDir, "fresh.mp4").writeText("fresh") + freshDir.setLastModified(now - 1000L) + + MediaExportHelper.sweepOldShareFiles(sharedDir, now) + + assertFalse(oldDir.exists()) + assertTrue(freshDir.exists()) + assertTrue(File(freshDir, "fresh.mp4").exists()) + } + + // ── 10. Complete Auth Matrix Tests ─────────────────────────────────────── + + private fun testAuthInterceptor( + baseUrl: String, + token: String?, + isGated: Boolean, + requestUrl: String, + existingHeader: String? = null, + ): String? { + val interceptor = + MediaDataSourceProvider.SameOriginAuthInterceptor( + baseUrlProvider = { baseUrl }, + tokenProvider = { token }, + isGatedModeProvider = { isGated }, + ) + var executedRequest: Request? = null + val chain = + mockk { + val reqBuilder = Request.Builder().url(requestUrl) + if (existingHeader != null) { + reqBuilder.header("Authorization", existingHeader) + } + every { request() } returns reqBuilder.build() + every { proceed(any()) } answers { + executedRequest = firstArg() + Response + .Builder() + .request(firstArg()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody("text/plain".toMediaType())) + .build() + } + } + interceptor.intercept(chain) + return executedRequest?.header("Authorization") + } + + @Test + fun `SameOriginAuthInterceptor satisfies complete authorization matrix`() { + // 1. gated mode, same origin -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "secret", + isGated = true, + requestUrl = "http://192.168.1.50:9119/api/files/stream?path=audio.mp3", + ), + ) + + // 2. gated mode, external origin -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "secret", + isGated = true, + requestUrl = "https://external.com/audio.mp3", + ), + ) + + // 3. token mode, same scheme/host/port, token set -> Bearer added + assertEquals( + "Bearer valid-token", + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "valid-token", + isGated = false, + requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4", + ), + ) + + // 4. token mode, token blank -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = " ", + isGated = false, + requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4", + ), + ) + + // 5. different host -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "valid-token", + isGated = false, + requestUrl = "http://192.168.1.99:9119/api/files/download?path=video.mp4", + ), + ) + + // 6. same host but different port -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "valid-token", + isGated = false, + requestUrl = "http://192.168.1.50:8080/api/files/download?path=video.mp4", + ), + ) + + // 7. same host/port but different scheme (https vs http) -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "valid-token", + isGated = false, + requestUrl = "https://192.168.1.50:9119/api/files/download?path=video.mp4", + ), + ) + + // 8. request already contains Authorization -> preserve existing value + assertEquals( + "Basic credentials", + testAuthInterceptor( + baseUrl = "http://192.168.1.50:9119/", + token = "valid-token", + isGated = false, + requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4", + existingHeader = "Basic credentials", + ), + ) + + // 9. base URL invalid -> no Bearer + assertNull( + testAuthInterceptor( + baseUrl = "not-a-valid-url", + token = "valid-token", + isGated = false, + requestUrl = "http://192.168.1.50:9119/api/files/download?path=video.mp4", + ), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt new file mode 100644 index 00000000..877bb236 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaPlaybackClassificationTest { + @Test + fun `audio MIME wins over mp4 extension`() { + val attachment = Attachment("content://media/123", "recording.mp4", "audio/mp4") + assertTrue(attachment.isAudio) + assertFalse(attachment.isVideo) + } + + @Test + fun `audio extensions route generic attachments into player`() { + for (ext in listOf("mp3", "m4a", "wav", "ogg", "opus", "flac", "aac")) { + assertTrue(Attachment("content://media/123", "recording.$ext", "application/octet-stream").isAudio) + } + } + + @Test + fun `video signed URL ignores query and fragment`() { + assertEquals(MediaKind.VIDEO, classifyMedia(uri = "https://example.org/clip.MP4?token=test#fragment")) + assertEquals(MediaKind.FILE, classifyMedia(uri = "https://example.org/report.txt?note=clip.mp4")) + } + + @Test + fun `gateway encoded path classifies audio`() { + assertEquals(MediaKind.AUDIO, classifyMedia(uri = "https://gateway/api/files/download?path=%2Ftmp%2Fa.mp3")) + } + + @Test + fun `media exports use media extensions not image fallback`() { + assertEquals("mp3", extensionForMime("audio/mpeg")) + assertEquals("m4a", extensionForMime("audio/mp4")) + assertEquals("mp4", extensionForMime("video/mp4")) + assertEquals("wav", extensionForMime("audio/x-wav")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt new file mode 100644 index 00000000..12fe98ca --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt @@ -0,0 +1,807 @@ +package com.m57.hermescontrol.ui.chat + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import com.m57.hermescontrol.data.config.ServerStore +import com.m57.hermescontrol.data.config.ServerStoreState +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.toEntity +import com.m57.hermescontrol.data.local.toUiModel +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.notification.ActiveReplyInfo +import com.m57.hermescontrol.notification.ChatNotificationService +import com.m57.hermescontrol.notification.ReplyNotificationTarget +import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.notification.TurnCorrelationTracker +import com.m57.hermescontrol.notification.TurnRowResolver +import com.m57.hermescontrol.notification.correlateCompletedTurnRow +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Regression matrix for reply-notification read dismissal. + * + * Split by design: + * - The `alreadyResolvedServerId*` cases start from a durable server row id that + * the notification already carries, and prove hydration binds it to that exact + * row only — the downstream half of the contract. + * - [mobileTurnBoundaryResolvesTheExactRowThenOnlyThatRowDismisses] covers the + * production path that PRODUCES that id (turn boundary → unique row). + * - The mobile-side resolution rules themselves live in + * `TurnCorrelationTrackerTest`, and the send-order guarantee in + * `ChatViewModelTest`. + * + * Nothing here compares Android notification time with Hermes server time; + * server row timestamps are not identity. + */ +class ReadNotificationReviewRegressionTest { + private lateinit var context: Context + private lateinit var manager: NotificationManager + + @Before + fun setUp() { + context = mockk(relaxed = true) + manager = mockk(relaxed = true) + every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns manager + mockkObject(AuthManager) + val mockStore = mockk(relaxed = true) + every { mockStore.getLatestState() } returns ServerStoreState(baseUrl = "http://localhost:8080") + every { AuthManager.serverStore } returns mockStore + every { AuthManager.getToken() } returns "token" + every { AuthManager.activeProfileId } returns MutableStateFlow("default") + ReplyNotificationTracker.resetForTest() + } + + @After + fun tearDown() { + ReplyNotificationTracker.resetForTest() + unmockkObject(AuthManager) + } + + private fun postTarget(text: String = "Done") { + ReplyNotificationTracker.onReplyNotificationPosted("default", "session", "new-completion", text, 1L) + } + + @Test + fun roomRoundTripMustRetainCompletionIdentity() { + val live = ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "new-completion") + val restored = live.toEntity("session").toUiModel() + assertEquals("Room reload lost notification identity", "new-completion", restored.completionId) + } + + @Test + fun repeatedTextInHistoryMustNotCopyLatestIdentityOntoOlderMessage() { + val live = + listOf( + ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "old-completion"), + ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "new-completion"), + ) + val history = + listOf( + SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")), + SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")), + ) + val mapped = mapServerMessages("session", history, 0, true, live) + assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId }) + } + + @Test + fun viewingOldCachedTextMustNotClearNewReplyNotification() { + postTarget("Done") + val old = + ChatMessage(role = MessageRole.ASSISTANT, content = "Done yesterday; unrelated old response") + .toEntity("session") + .toUiModel() + val cancelled = + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + old.completionId, + ) + assertFalse("Old text incorrectly acknowledged the new reply", cancelled) + verify(exactly = 0) { manager.cancel(any()) } + } + + @Test + fun strippedCommentaryCompletionMustBindTerminalVisibleMessage() { + val orphan = ChatMessage(id = "orphan", role = MessageRole.ASSISTANT, content = "already shown") + val state = ChatUiState(currentSessionId = "session", messages = listOf(orphan)) + val streaming = StreamingState(streamingMessage = orphan, sealedOrphanIds = listOf("orphan")) + val result = + ChatWsEventReducer.reduce( + state, + streaming, + WsEvent.MessageComplete("already shown", "session", completionId = "new-completion"), + "session", + ) + assertEquals( + "Completion lost when no new final bubble is needed", + "new-completion", + result.state.messages + .single() + .completionId, + ) + val persistEffect = + result.effects.filterIsInstance().singleOrNull() + assertEquals( + "PersistMessage effect must be emitted for the sealed orphan bubble so Room persists it", + "new-completion", + persistEffect?.message?.completionId, + ) + } + + @Test + fun loadOlderMessagesMustNotStealCompletionIdFromLiveMessage() { + val live = + listOf( + ChatMessage( + id = "ws-1", + role = MessageRole.ASSISTANT, + content = "Done", + completionId = "new-completion", + ), + ) + val olderHistory = + listOf( + SessionMessage(id = 50, role = "assistant", content = JsonPrimitive("Done")), + ) + val mapped = mapServerMessages("session", olderHistory, 50, true, live, isPagingOlder = true) + assertEquals("Older paged message must not steal live completionId", null, mapped.single().completionId) + } + + @Test + fun duplicateTextOutsideFetchedPageMustNotShiftCompletionId() { + val live = + listOf( + ChatMessage( + id = "rest-session-10", + role = MessageRole.ASSISTANT, + content = "Done", + completionId = null, + ), + ChatMessage( + id = "rest-session-20", + role = MessageRole.ASSISTANT, + content = "Done", + completionId = "new-completion", + ), + ) + // Page only includes message 20 (message 10 is outside this page) + val history = + listOf( + SessionMessage(id = 20, role = "assistant", content = JsonPrimitive("Done")), + ) + val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) + assertEquals( + "Exact REST id match must preserve identity regardless of older duplicates", + "new-completion", + mapped.single().completionId, + ) + } + + @Test + fun metadataFreeReplacementMustNotBeCancelled() { + postTarget() + ReplyNotificationTracker.activeNotificationProvider = { + ActiveReplyInfo(2, null, null, null, null, null, 0L, 1L) + } + assertFalse(ReplyNotificationTracker.onMessageVisible(context, "default", "session", "new-completion")) + verify(exactly = 0) { manager.cancel(any()) } + } + + @Test + fun actionPostingMustNotInterleaveBetweenInspectionAndCancellation() { + val reply = + ActiveReplyInfo( + 2, + ReplyNotificationTracker.KIND_REPLY, + "default", + "session", + "new-completion", + "Done", + 1L, + 1L, + ) + val actualSlot = AtomicReference("reply") + val ready = CountDownLatch(1) + val allowNotify = CountDownLatch(1) + val posted = CountDownLatch(1) + val action = mockk() + every { manager.notify(2, action) } answers { actualSlot.set("action") } + every { manager.cancel(2) } answers { actualSlot.set("empty") } + postTarget() + val writer = + Thread { + // Same split ownership/write sequence as showReplyNotification and NotificationReplyReceiver. + ReplyNotificationTracker.onNonReplyNotificationPosted() + ready.countDown() + check(allowNotify.await(5, TimeUnit.SECONDS)) + manager.notify(2, action) + posted.countDown() + } + writer.start() + assertTrue(ready.await(5, TimeUnit.SECONDS)) + var reads = 0 + ReplyNotificationTracker.activeNotificationProvider = { + val snapshot = reply + reads++ + if (reads == 2) { + // Pause after querying activeNotifications but before cancel(), then complete the action write. + allowNotify.countDown() + check(posted.await(5, TimeUnit.SECONDS)) + } + snapshot + } + try { + ReplyNotificationTracker.onMessageVisible(context, "default", "session", "new-completion") + } finally { + allowNotify.countDown() + writer.join(6000) + } + assertEquals("New action notification was cancelled by the old read", "action", actualSlot.get()) + } + + @Test + fun oldReplyRacingNewerActionMustNotPostOrOverwriteAction() { + val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Old Reply") + val actionNotif = mockk() + val replyNotif = mockk() + + // Action arrives and posts + assertTrue(ReplyNotificationTracker.postActionNotification(context, actionNotif)) + verify(exactly = 1) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, actionNotif) } + + // Stale reply 1 tries to post after action arrived + val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, gen1) + assertFalse("Stale reply must be rejected after action notification was posted", posted) + verify(exactly = 0) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) } + assertNull("Active target must remain null for action", ReplyNotificationTracker.getActiveTarget()) + } + + @Test + fun oldReplyRacingNewerReplyMustNotOverwriteNewerReply() { + val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Old Reply") + val gen2 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-2", "New Reply") + val replyNotif1 = mockk() + val replyNotif2 = mockk() + + // Gen 2 posts successfully + assertTrue(ReplyNotificationTracker.postReplyNotification(context, replyNotif2, gen2)) + assertEquals("comp-2", ReplyNotificationTracker.getActiveTarget()?.completionId) + + // Stale Gen 1 tries to post + assertFalse(ReplyNotificationTracker.postReplyNotification(context, replyNotif1, gen1)) + assertEquals("comp-2", ReplyNotificationTracker.getActiveTarget()?.completionId) + } + + @Test + fun readCancelRacingReplyPublicationMustPreventZombieNotification() { + val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Pending Reply") + val replyNotif = mockk() + + // User views message in foreground chat before background service calls postReplyNotification + val cancelled = + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + "comp-1", + ) + assertTrue("Message visibility should cancel the pending reply target", cancelled) + + // Delayed background service finally tries to post reply + val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, gen1) + assertFalse("Tombstoned/cancelled generation must not post notification ID 2", posted) + verify(exactly = 0) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) } + } + + @Test + fun mediaRepliesRetainCompletionIdAcrossRestRehydration() { + val live = + listOf( + ChatMessage( + id = "ws-media", + role = MessageRole.ASSISTANT, + content = "Here is your chart", + completionId = "comp-media-123", + ), + ) + val history = + listOf( + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Here is your chart MEDIA:/opt/hermes/chart.png"), + ), + ) + val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) + assertEquals("comp-media-123", mapped.single().completionId) + } + + @Test + fun mediaOnlyReplyRetainsCompletionIdAcrossRestRehydration() { + val live = + listOf( + ChatMessage( + id = "ws-media-only", + role = MessageRole.ASSISTANT, + content = "", + completionId = "comp-media-only", + ), + ) + val history = + listOf( + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("MEDIA:/opt/hermes/photo.jpg"), + ), + ) + val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) + assertEquals("comp-media-only", mapped.single().completionId) + } + + @Test + fun mediaReplyWithReasoningOnlyRowBeforeItRetainsCompletionId() { + val live = + listOf( + ChatMessage( + id = "ws-media-reasoning", + role = MessageRole.ASSISTANT, + content = "The result", + completionId = "comp-reasoning-media", + ), + ) + val history = + listOf( + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive(""), + reasoning = JsonPrimitive("thinking step"), + ), + SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")), + ) + val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) + assertEquals("comp-reasoning-media", mapped.single().completionId) + } + + @Test + fun blankMessageCompleteMustNotCorruptHistoricalAssistantMessage() { + val oldAssistant = + ChatMessage( + id = "old-assistant", + role = MessageRole.ASSISTANT, + content = "Old reply from yesterday", + completionId = "old-comp", + ) + val state = ChatUiState(currentSessionId = "session", messages = listOf(oldAssistant)) + val streaming = StreamingState() // No current streaming message or sealed orphans + + val result = + ChatWsEventReducer.reduce( + state, + streaming, + WsEvent.MessageComplete("", "session", completionId = "new-unrelated-completion"), + "session", + ) + + // Historical assistant message must be completely untouched + assertEquals( + "old-comp", + result.state.messages + .single() + .completionId, + ) + assertFalse( + "PersistMessage effect must not be emitted for unrelated historical messages", + result.effects.any { it is ReducerEffect.PersistMessage }, + ) + } + + @Test + fun coldStartRecoveredGenerationMustAdvanceCounterAndAllowFutureReplies() { + ReplyNotificationTracker.resetForTest() + // Active notification in system with generation 37 + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-37", + textSnippet = "Reply 37", + generation = 37L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + // User views message -> recovers and cancels generation 37 + val cancelled = ReplyNotificationTracker.onMessageVisible(context, "default", "session", "comp-37") + assertTrue("Generation 37 should be recovered and cancelled", cancelled) + + // New reply arrives + val newGen = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-38", "Reply 38") + assertTrue("New generation must exceed recovered generation (was $newGen)", newGen > 37L) + + val replyNotif = mockk() + val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, newGen) + assertTrue("New reply notification must be accepted and posted", posted) + verify { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) } + } + + @Test + fun recoveredGenerationFollowedByActionAndReplyMustRemainStrictlyMonotonic() { + ReplyNotificationTracker.resetForTest() + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-37", + textSnippet = "Reply 37", + generation = 37L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + // Recover and cancel 37 + assertTrue(ReplyNotificationTracker.onMessageVisible(context, "default", "session", "comp-37")) + + // Post action + val actionNotif = mockk() + assertTrue(ReplyNotificationTracker.postActionNotification(context, actionNotif)) + + // Register new reply + val newGen = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-39", "Reply 39") + assertTrue("New generation must strictly exceed 37 and action tombstone", newGen > 38L) + + val replyNotif = mockk() + assertTrue(ReplyNotificationTracker.postReplyNotification(context, replyNotif, newGen)) + } + + @Test + fun alreadyResolvedServerIdBindsOnlyTheExactRowAmongShortDuplicates() { + ReplyNotificationTracker.resetForTest() + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-newest-done", + serverMessageId = 2, + textSnippet = "Done", + generation = 5L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + // Two identical "Done" rows. The durable id (row 2) is what decides — + // there is no server row timestamp comparison anywhere in the mapper. + val history = + listOf( + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Done"), + ), + SessionMessage( + id = 2, + role = "assistant", + content = JsonPrimitive("Done"), + ), + ) + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + assertEquals("Older message must have null completionId", null, mapped[0].completionId) + assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId) + + // Showing ONLY the older Done must NOT cancel the notification + val cancelledOld = + ReplyNotificationTracker.onMessageVisible( + context = context, + scopeId = "default", + sessionId = "session", + completionId = mapped[0].completionId, + ) + assertFalse("Viewing older duplicate message must NOT cancel notification", cancelledOld) + verify(exactly = 0) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + + // Showing the newest Done MUST cancel the notification + val cancelledNew = + ReplyNotificationTracker.onMessageVisible( + context = context, + scopeId = "default", + sessionId = "session", + completionId = mapped[1].completionId, + ) + assertTrue("Viewing newest message must cancel notification", cancelledNew) + verify(exactly = 1) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun alreadyResolvedServerIdIgnoresSharedHundredCharacterPrefix() { + ReplyNotificationTracker.resetForTest() + val textPrefix = "x".repeat(100) + val text1 = textPrefix + "A" + val text2 = textPrefix + "B" + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-long-2", + serverMessageId = 10, + textSnippet = text2.take(100), + generation = 5L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + val history = + listOf( + SessionMessage( + id = 10, + role = "assistant", + content = JsonPrimitive(text1), + ), + SessionMessage( + id = 20, + role = "assistant", + content = JsonPrimitive(text2), + ), + ) + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + assertEquals("comp-long-2", mapped[0].completionId) + assertEquals(null, mapped[1].completionId) + + assertTrue( + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + mapped[0].completionId, + ), + ) + assertFalse( + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + mapped[1].completionId, + ), + ) + } + + @Test + fun alreadyResolvedServerIdBindsMediaRowAndDismissesOnVisible() { + ReplyNotificationTracker.resetForTest() + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-media-cold", + serverMessageId = 100, + textSnippet = "Here is the image MEDIA:/opt/hermes/image.png", + generation = 5L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + val history = + listOf( + SessionMessage( + id = 100, + role = "assistant", + content = JsonPrimitive("Here is the image MEDIA:/opt/hermes/image.png"), + ), + ) + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + val row = mapped.single() + assertEquals("comp-media-cold", row.completionId) + assertEquals("Here is the image", row.content) + + val dismissed = + ReplyNotificationTracker.onMessageVisible( + context = context, + scopeId = "default", + sessionId = "session", + completionId = row.completionId, + ) + assertTrue("Viewing hydrated MEDIA row must dismiss notification", dismissed) + verify { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun alreadyResolvedServerIdBindsMediaOnlyRowAndDismissesOnVisible() { + ReplyNotificationTracker.resetForTest() + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-media-only-cold", + serverMessageId = 100, + textSnippet = "MEDIA:/opt/hermes/image.png", + generation = 5L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + val history = + listOf( + SessionMessage( + id = 100, + role = "assistant", + content = JsonPrimitive("MEDIA:/opt/hermes/image.png"), + ), + ) + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + val row = mapped.single() + assertEquals("comp-media-only-cold", row.completionId) + assertEquals("", row.content) + + val dismissed = + ReplyNotificationTracker.onMessageVisible( + context = context, + scopeId = "default", + sessionId = "session", + completionId = row.completionId, + ) + assertTrue("Viewing hydrated MEDIA-only row must dismiss notification", dismissed) + verify { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun alreadyResolvedServerIdBindsOnlyTheExactMediaRowAmongDuplicates() { + ReplyNotificationTracker.resetForTest() + val activeInfo = + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-media-dup-2", + serverMessageId = 1, + textSnippet = "MEDIA:/opt/hermes/image.png", + generation = 5L, + timestamp = System.currentTimeMillis(), + ) + ReplyNotificationTracker.activeNotificationProvider = { activeInfo } + + val history = + listOf( + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("MEDIA:/opt/hermes/image.png"), + ), + SessionMessage( + id = 2, + role = "assistant", + content = JsonPrimitive("MEDIA:/opt/hermes/image.png"), + ), + ) + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + assertEquals("comp-media-dup-2", mapped[0].completionId) + assertEquals(null, mapped[1].completionId) + + assertTrue( + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + mapped[0].completionId, + ), + ) + assertFalse( + ReplyNotificationTracker.onMessageVisible( + context, + "default", + "session", + mapped[1].completionId, + ), + ) + } + + /** + * The path production actually runs, end to end: the turn boundary armed + * before the prompt names exactly one REST row above it, that row id travels + * in the notification, only that row is hydrated with the completion id, and + * only that row dismisses the notification. + */ + @Test + fun mobileTurnBoundaryResolvesTheExactRowThenOnlyThatRowDismisses() { + ReplyNotificationTracker.resetForTest() + TurnCorrelationTracker.resetForTest() + TurnCorrelationTracker.armBoundary("default", "session", 50) + + val history = + listOf( + SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")), + SessionMessage(id = 51, role = "user", content = JsonPrimitive("next")), + SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")), + ) + val resolved = + runBlocking { + correlateCompletedTurnRow( + scopeId = "default", + sessionId = "session", + completionText = "Done", + resolver = TurnRowResolver({ history }, listOf(0L)), + ) + } + assertEquals("The historical duplicate below the boundary must not win", 52, resolved) + + // The service stores that id in the notification extras; after process + // death the extras are all the tracker can recover from. + ReplyNotificationTracker.activeNotificationProvider = { + ActiveReplyInfo( + id = ChatNotificationService.PENDING_NOTIFICATION_ID, + kind = ReplyNotificationTracker.KIND_REPLY, + scopeId = "default", + sessionId = "session", + completionId = "comp-turn", + textSnippet = "Done", + generation = 5L, + timestamp = 0L, + serverMessageId = resolved, + ) + } + + val mapped = + mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId }) + + assertFalse( + "Viewing the older duplicate must not dismiss", + ReplyNotificationTracker.onMessageVisible(context, "default", "session", mapped[0].completionId), + ) + assertTrue( + "Viewing the turn's own row must dismiss", + ReplyNotificationTracker.onMessageVisible(context, "default", "session", mapped[2].completionId), + ) + verify(exactly = 1) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) } + } + + @Test + fun unknownScopeMustNotAcknowledgeScopedReply() { + val target = ReplyNotificationTarget("other-profile", "session", "completion", 1L, "Done") + assertFalse(target.matches(null, "session", "completion")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 9df5f047..95c33f4a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -98,6 +98,7 @@ class SlashCommandDispatchRpcTest { every { AuthManager.isUserMessageTokensEnabled() } returns true every { AuthManager.isAssistantMessageTokensEnabled() } returns true every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserverTest.kt new file mode 100644 index 00000000..58b8ef72 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserverTest.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatReadObserverTest { + private fun createMockItem( + key: Any, + offset: Int, + size: Int, + ): LazyListItemInfo { + val item = mockk() + every { item.key } returns key + every { item.offset } returns offset + every { item.size } returns size + return item + } + + private fun createMockLayoutInfo( + items: List, + startOffset: Int = 0, + endOffset: Int = 1000, + ): LazyListLayoutInfo { + val layout = mockk() + every { layout.visibleItemsInfo } returns items + every { layout.viewportStartOffset } returns startOffset + every { layout.viewportEndOffset } returns endOffset + return layout + } + + @Test + fun `returns empty when visible items or messages are empty`() { + val layout = createMockLayoutInfo(emptyList()) + val msg = + ChatMessage( + id = "msg-1", + role = MessageRole.ASSISTANT, + content = "Hello", + ) + + assertTrue(ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msg)).isEmpty()) + + val layoutWithItem = createMockLayoutInfo(listOf(createMockItem("prose-msg-1", 100, 50))) + assertTrue(ChatReadObserver.findVisibleAssistantMessages(layoutWithItem, emptyList()).isEmpty()) + } + + @Test + fun `identifies visible completed assistant messages`() { + val msg1 = + ChatMessage( + id = "msg-1", + role = MessageRole.ASSISTANT, + content = "Answer 1", + completionId = "comp-1", + ) + val msg2 = + ChatMessage( + id = "msg-2", + role = MessageRole.ASSISTANT, + content = "Answer 2", + completionId = "comp-2", + ) + + val items = + listOf( + createMockItem("prose-msg-1", 100, 200), + createMockItem("prose-msg-2", 400, 250), + ) + val layout = createMockLayoutInfo(items) + + val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msg1, msg2)) + assertEquals(2, visible.size) + assertEquals("msg-1", visible[0].id) + assertEquals("msg-2", visible[1].id) + } + + @Test + fun `ignores non-prose items like user bubbles, reasoning cards, and tool rows`() { + val assistant = + ChatMessage( + id = "ast-1", + role = MessageRole.ASSISTANT, + content = "Done", + ) + val user = + ChatMessage( + id = "usr-1", + role = MessageRole.USER, + content = "Question", + ) + + val items = + listOf( + createMockItem("user-usr-1", 50, 100), + createMockItem("reasoning-ast-1", 150, 100), + createMockItem("tool-tool-1", 250, 100), + createMockItem("prose-ast-1", 350, 100), + ) + val layout = createMockLayoutInfo(items) + + val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(assistant, user)) + assertEquals(1, visible.size) + assertEquals("ast-1", visible.single().id) + } + + @Test + fun `ignores streaming assistant messages`() { + val streaming = + ChatMessage( + id = "stream-1", + role = MessageRole.ASSISTANT, + content = "typing...", + isStreaming = true, + ) + + val items = listOf(createMockItem("prose-stream-1", 100, 200)) + val layout = createMockLayoutInfo(items) + + val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(streaming)) + assertTrue(visible.isEmpty()) + } + + @Test + fun `filters out items outside viewport bounds`() { + val msgAbove = + ChatMessage( + id = "above", + role = MessageRole.ASSISTANT, + content = "Above viewport", + ) + val msgInside = + ChatMessage( + id = "inside", + role = MessageRole.ASSISTANT, + content = "Inside viewport", + ) + val msgBelow = + ChatMessage( + id = "below", + role = MessageRole.ASSISTANT, + content = "Below viewport", + ) + + // Viewport is 200..800 + val items = + listOf( + // Fully above: offset -100, size 100 -> offset + size = 0 <= 200 + createMockItem("prose-above", -100, 100), + // Inside: offset 300, size 150 + createMockItem("prose-inside", 300, 150), + // Fully below: offset 900, size 100 >= 800 + createMockItem("prose-below", 900, 100), + ) + val layout = createMockLayoutInfo(items, startOffset = 200, endOffset = 800) + + val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msgAbove, msgInside, msgBelow)) + assertEquals(1, visible.size) + assertEquals("inside", visible.single().id) + } + + @Test + fun `includes partially visible messages at viewport edges`() { + val topPartial = + ChatMessage( + id = "top", + role = MessageRole.ASSISTANT, + content = "Top edge", + ) + val bottomPartial = + ChatMessage( + id = "bottom", + role = MessageRole.ASSISTANT, + content = "Bottom edge", + ) + + // Viewport 100..500 + val items = + listOf( + // Crosses top boundary: offset 50, size 100 (bottom is 150 > 100) + createMockItem("prose-top", 50, 100), + // Crosses bottom boundary: offset 450, size 100 (top is 450 < 500) + createMockItem("prose-bottom", 450, 100), + ) + val layout = createMockLayoutInfo(items, startOffset = 100, endOffset = 500) + + val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(topPartial, bottomPartial)) + assertEquals(2, visible.size) + assertEquals("top", visible[0].id) + assertEquals("bottom", visible[1].id) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt new file mode 100644 index 00000000..592c893b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.components + +import com.m57.hermescontrol.theme.CodeKeyword +import com.m57.hermescontrol.theme.CodeNumber +import com.m57.hermescontrol.theme.CodePunctuation +import com.m57.hermescontrol.theme.CodeString +import org.junit.Assert.assertNotNull +import org.junit.Test + +class SyntaxHighlightingTest { + @Test + fun `highlightSyntax highlights json keys as keyword and values as string`() { + val json = """{"status": "running", "code": 0}""" + val result = highlightSyntax(json) + + val styles = result.spanStyles + val keySpan = styles.find { it.item.color == CodeKeyword } + assertNotNull("JSON key should be highlighted with CodeKeyword", keySpan) + + val stringSpan = styles.find { it.item.color == CodeString } + assertNotNull("String value should be highlighted with CodeString", stringSpan) + + val numberSpan = styles.find { it.item.color == CodeNumber } + assertNotNull("Number value should be highlighted with CodeNumber", numberSpan) + + val punctSpan = styles.find { it.item.color == CodePunctuation } + assertNotNull("Colon/braces should be highlighted with CodePunctuation", punctSpan) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt new file mode 100644 index 00000000..bd8430bf --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.ui.chat.tool + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ToolJsonTest { + @Test + fun `prettyPrintJson formats minified json object line-by-line with indentation`() { + val raw = """{"name":"test","count":42,"active":true}""" + val formatted = ToolJson.prettyPrintJson(raw) + + assertTrue(formatted.contains("\n")) + assertTrue(formatted.contains(" \"name\": \"test\"")) + assertTrue(formatted.contains(" \"count\": 42")) + assertTrue(formatted.contains(" \"active\": true")) + } + + @Test + fun `prettyPrintJson formats json array with indentation`() { + val raw = """[{"id":1},{"id":2}]""" + val formatted = ToolJson.prettyPrintJson(raw) + + assertTrue(formatted.contains("\n")) + assertTrue(formatted.contains(" {\n \"id\": 1\n }")) + } + + @Test + fun `prettyPrintJson returns non-json content unchanged`() { + val raw = "Execution failed with exit code 1" + val formatted = ToolJson.prettyPrintJson(raw) + + assertEquals(raw, formatted) + } + + @Test + fun `prettyPrintJson returns empty and blank strings unchanged`() { + assertEquals("", ToolJson.prettyPrintJson("")) + assertEquals(" ", ToolJson.prettyPrintJson(" ")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index 7b408a4f..425b3464 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -281,6 +281,35 @@ class ToolViewBuilderTest { assertTrue(view.detail.contains("sleep 100")) } + @Test + fun `running process_manage shows pending detail instead of completion`() { + val view = + build( + "process_manage", + """{"action":"wait","session_id":"proc-1"}""", + """{"tool_id":"call-1","name":"process_manage","args":{"action":"wait"}}""", + running = true, + ) + + assertEquals(ToolViewStatus.RUNNING, view.status) + assertEquals("⏳ wait in progress", view.detail) + assertFalse(view.detail.contains("done")) + } + + @Test + fun `process_manage poll renders output_preview`() { + val view = + build( + "process_manage", + """{"action":"poll","session_id":"proc-1"}""", + """{"status":"running","output_preview":"listening on port 8080"}""", + ) + + assertEquals("poll: proc-1", view.subtitle) + assertTrue(view.detail.contains("Status: running")) + assertTrue(view.detail.contains("listening on port 8080")) + } + @Test fun `todo_list alias activates TodoRenderer`() { val view = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index e12fae36..aeb33d05 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.tool +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -655,6 +656,76 @@ class ToolViewCharacterizationTest { assertEquals("${"x".repeat(500)}\n... [120 more chars]", view.detail) } + @Test + fun `skill_manage success renders a success detail and status`() { + val view = + build( + "skill_manage", + """{"action":"create","name":"summarize"}""", + """{"success":true,"message":"Skill 'summarize' created."}""", + ) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("✅ Skill 'summarize' created.", view.detail) + } + + @Test + fun `skill_manage explicit failure renders a failure detail`() { + val call = + ToolCall.of( + "skill_manage", + Json.parseToJsonElement("""{"action":"create","name":"summarize"}"""), + Json.parseToJsonElement("""{"success":false,"message":"Permission denied"}"""), + ) + + assertEquals("❌ Permission denied", SkillManageRenderer.detail(call)) + } + + @Test + fun `skill_manage failure status and detail agree`() { + val view = + build( + "skill_manage", + """{"action":"create","name":"summarize"}""", + """{"success":false,"message":"Permission denied"}""", + ) + + assertEquals(ToolViewStatus.ERROR, view.status) + assertTrue(view.detail.contains("❌ Permission denied")) + assertTrue(!view.detail.contains("✅")) + } + + @Test + fun `skill_manage error field remains the failure detail`() { + val call = + ToolCall.of( + "skill_manage", + Json.parseToJsonElement("""{"action":"create","name":"summarize"}"""), + Json.parseToJsonElement("""{"success":false,"error":"Permission denied"}"""), + ) + + assertEquals("❌ Permission denied", SkillManageRenderer.detail(call)) + } + + @Test + fun `skill_manage missing or invalid success fails closed`() { + val missing = + ToolCall.of( + "skill_manage", + null, + Json.parseToJsonElement("""{"message":"unexpected payload"}"""), + ) + val invalid = + ToolCall.of( + "skill_manage", + null, + Json.parseToJsonElement("""{"success":"true","message":"unexpected payload"}"""), + ) + + assertEquals("❌ unexpected payload", SkillManageRenderer.detail(missing)) + assertEquals("❌ unexpected payload", SkillManageRenderer.detail(invalid)) + } + @Test fun `tool_search shows match count and entries`() { val view = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/render/FileEditSupportTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/render/FileEditSupportTest.kt new file mode 100644 index 00000000..eefed8a2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/render/FileEditSupportTest.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FileEditSupportTest { + @Test + fun testCountDiffLineStats_emptyDiff() { + val stats = FileEditSupport.countDiffLineStats("") + assertEquals(0, stats.added) + assertEquals(0, stats.removed) + } + + @Test + fun testCountDiffLineStats_headerFiltering() { + val diff = + """ + --- a/file.txt + +++ b/file.txt + @@ -1,5 +1,5 @@ + unchanged + -removed line + +added line + """.trimIndent() + val stats = FileEditSupport.countDiffLineStats(diff) + assertEquals(1, stats.added) + assertEquals(1, stats.removed) + } + + @Test + fun testCountDiffLineStats_crlfHandling() { + val diff = "---\r\n+++\r\n+added\r\n-removed\r\n+another\r\n" + val stats = FileEditSupport.countDiffLineStats(diff) + assertEquals(2, stats.added) + assertEquals(1, stats.removed) + } + + @Test + fun testCountDiffLineStats_largeDiff() { + val sb = java.lang.StringBuilder() + sb.append("--- a/file.txt\n") + sb.append("+++ b/file.txt\n") + + val count = 5000 + for (i in 0 until count) { + sb.append("-old line $i\n") + sb.append("+new line $i\n") + } + + val stats = FileEditSupport.countDiffLineStats(sb.toString()) + assertEquals(count, stats.added) + assertEquals(count, stats.removed) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt index 962e6b37..6c727470 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt @@ -1,16 +1,22 @@ package com.m57.hermescontrol.ui.common import androidx.lifecycle.ViewModel +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -18,6 +24,9 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ViewModelExtTest { private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private class TestViewModel : ViewModel() @Before fun setUp() { @@ -29,46 +38,149 @@ class ViewModelExtTest { Dispatchers.resetMain() } - private class TestViewModel : ViewModel() - @Test - fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() = - runTest(testDispatcher) { + fun testNormalSwrCacheHitInvokesOnCacheHitImmediately() = + testScope.runTest { val vm = TestViewModel() - var started = false - var successResult: String? = null - var completed = false + val cache = SwrCache() + val scope = DataScope("default", "http://server", "main", 0L) + val scopedKey = scope.scopedKey("default") + cache.put(scopedKey, "cached-val") - vm.safeLaunchAction( - onStart = { started = true }, - apiCall = { NetworkResult.Success("ok") }, - onSuccess = { successResult = it }, + var cacheHit: String? = null + var success: String? = null + + vm.safeLaunchSwrLoad( + cache = cache, + requestScope = scope, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Success("fresh-val") }, + onStart = {}, + onSuccess = { success = it }, onError = {}, - onComplete = { completed = true }, ) - assertTrue(started) + // Cache hit happens synchronously before coroutine dispatches + assertEquals("cached-val", cacheHit) + testDispatcher.scheduler.advanceUntilIdle() - assertEquals("ok", successResult) - assertTrue(completed) + assertEquals("fresh-val", success) + assertEquals("fresh-val", cache.get(scopedKey)) } @Test - fun `safeLaunchAction triggers onError and onComplete on failure`() = - runTest(testDispatcher) { + fun testForceRefreshBypassesCacheHitAndEmitsErrors() = + testScope.runTest { val vm = TestViewModel() - var errorMessage: String? = null - var completed = false + val cache = SwrCache() + val scope = DataScope("default", "http://server", "main", 0L) + val scopedKey = scope.scopedKey("default") + cache.put(scopedKey, "cached-val") - vm.safeLaunchAction( - apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) }, + var cacheHit: String? = null + var errorReceived: String? = null + + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = true, + requestScope = scope, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) }, + onStart = {}, onSuccess = {}, - onError = { errorMessage = it }, - onComplete = { completed = true }, + onError = { errorReceived = it }, ) + // Force refresh does NOT hit cache + assertEquals(null, cacheHit) + testDispatcher.scheduler.advanceUntilIdle() - assertEquals("network error", errorMessage) - assertTrue(completed) + assertEquals("Server Error", errorReceived) + } + + @Test + fun testSilentBackgroundFailurePreservesCachedContent() = + testScope.runTest { + val vm = TestViewModel() + val cache = SwrCache() + val scope = DataScope("default", "http://server", "main", 0L) + val scopedKey = scope.scopedKey("default") + cache.put(scopedKey, "cached-val") + + var cacheHit: String? = null + var errorReceived: String? = null + + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = false, + requestScope = scope, + scopeIsCurrent = { true }, + onCacheHit = { cacheHit = it }, + apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) }, + onStart = {}, + onSuccess = {}, + onError = { errorReceived = it }, + ) + + assertEquals("cached-val", cacheHit) + + testDispatcher.scheduler.advanceUntilIdle() + // Error is suppressed on silent revalidation failure + assertNull(errorReceived) + assertEquals("cached-val", cache.get(scopedKey)) + } + + @Test + fun testForcedRefreshSupersedesActiveJob() = + testScope.runTest { + val vm = TestViewModel() + val cache = SwrCache() + val scope = DataScope("default", "http://server", "main", 0L) + + val deferred1 = CompletableDeferred>() + val deferred2 = CompletableDeferred>() + + var req1Success = false + var req2Success = false + + val job1 = + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = false, + requestScope = scope, + scopeIsCurrent = { true }, + onCacheHit = {}, + apiCall = { deferred1.await() }, + onStart = {}, + onSuccess = { req1Success = true }, + onError = {}, + ) + + val job2 = + vm.safeLaunchSwrLoad( + cache = cache, + forceRefresh = true, + currentJob = job1, + requestScope = scope, + scopeIsCurrent = { true }, + onCacheHit = {}, + apiCall = { deferred2.await() }, + onStart = {}, + onSuccess = { req2Success = true }, + onError = {}, + ) + + assertTrue(job1.isCancelled) + assertFalse(job2.isCancelled) + + deferred2.complete(NetworkResult.Success("resp-2")) + deferred1.complete(NetworkResult.Success("resp-1")) + + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(req2Success) + assertFalse(req1Success) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt index 89c8ed16..0e92ff81 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt @@ -7,6 +7,33 @@ import org.junit.Assert.assertNull import org.junit.Test class SessionSearchFormattingTest { + @Test + fun `wire title stays distinct from excerpt and preserves metadata`() { + val hit = + kotlinx.serialization.json.Json + .decodeFromString( + """{"session_id":"s","title":"Launch","snippet":"match","last_active":42,"message_count":7}""", + ).toSessionInfo() + assertEquals("Launch", hit.title) + assertEquals("match", hit.preview) + assertEquals(42.0, hit.last_active) + assertEquals(7, hit.message_count) + } + + @Test + fun `highlight phrases and words without Unicode offset drift`() { + val text = "İ 🌙 launch notes and deploy" + val result = + highlightSearchText( + text, + "\"launch notes\" deploy", + androidx.compose.ui.graphics.Color.Transparent, + androidx.compose.ui.graphics.Color.Unspecified, + ) + assertEquals(text, result.text) + assertEquals(listOf("launch notes", "deploy"), result.spanStyles.map { text.substring(it.start, it.end) }) + } + @Test fun `clean search snippet strips highlight markers from plain text`() { assertEquals( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index cbc12407..43cbe479 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -99,6 +99,66 @@ class SessionsViewModelTest { unmockkAll() } + @Test + fun `wire paging metadata drives a second request without changing history paging`() { + val first = + kotlinx.serialization.json.Json.decodeFromString( + """{"results":[{"session_id":"a"},{"session_id":"a"}],"has_more":true,"next_offset":20}""", + ) + coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 0) } returns Response.success(first) + coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 20) } returns + Response.success( + SessionSearchResponse(listOf(SessionSearchResult("b")), has_more = false), + ) + val vm = createViewModel() + vm.setSearchQuery("launch") + testDispatcher.scheduler.advanceUntilIdle() + vm.loadMoreSearch() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("a", "b"), + vm.uiState.value.searchResults + .map { it.session_id }, + ) + assertFalse(vm.uiState.value.searchHasMore) + assertFalse(vm.uiState.value.hasMore) + coVerify(exactly = 1) { mockApi.searchSessions("launch", null, null, "cron", 20, 20) } + } + + @Test + fun `bulk delete removes selected search hits and preserves others`() { + mockkObject(com.m57.hermescontrol.data.local.AuthManager) + every { + com.m57.hermescontrol.data.local.AuthManager + .getLastOpenedSessionId() + } returns null + coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 0) } returns + Response.success(SessionSearchResponse(listOf(SessionSearchResult("a"), SessionSearchResult("b")))) + coEvery { mockApi.bulkDeleteSessions(any()) } returns + Response.success( + com.m57.hermescontrol.data.model + .BulkDeleteResponse(ok = true, deleted = 1), + ) + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns + Response.success(SessionListResponse(sessions = emptyList(), total = 0)) + val vm = createViewModel() + vm.setSearchQuery("launch") + testDispatcher.scheduler.advanceUntilIdle() + vm.toggleSessionSelection("a") + vm.confirmBulkDelete() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("b"), + vm.uiState.value.searchResults + .map { it.session_id }, + ) + assertTrue( + vm.uiState.value.selectedIds + .isEmpty(), + ) + assertFalse(vm.uiState.value.isDeletingBulk) + } + @Test fun `tracking loads the project list`() { val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) } @@ -189,6 +249,67 @@ class SessionsViewModelTest { vm.stopLiveStatusTracking() } + @Test + fun `search is pending immediately before debounce`() { + val vm = createViewModel() + vm.setSearchQuery("deploy") + assertTrue(vm.uiState.value.isSearching) + assertNull(vm.uiState.value.searchError) + vm.setSearchQuery("") + assertFalse(vm.uiState.value.isSearching) + } + + @Test + fun `search pagination keeps results on failure and retries same offset`() { + coEvery { mockApi.searchSessions("deploy", null, null, "cron", 20, 0) } returns + Response.success(SessionSearchResponse(listOf(SessionSearchResult("one")), true, 1)) + coEvery { mockApi.searchSessions("deploy", null, null, "cron", 20, 1) } returns + Response.error(503, "busy".toResponseBody()) andThen + Response.error(503, "busy".toResponseBody()) andThen + Response.error(503, "busy".toResponseBody()) andThen + Response.success( + SessionSearchResponse(listOf(SessionSearchResult("one"), SessionSearchResult("two")), false), + ) + val vm = createViewModel() + vm.setSearchQuery("deploy") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(vm.uiState.value.searchHasMore) + vm.loadMoreSearch() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("one"), + vm.uiState.value.searchResults + .map { it.session_id }, + ) + assertNotNull(vm.uiState.value.searchLoadMoreError) + assertNull(vm.uiState.value.searchError) + vm.loadMoreSearch() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("one", "two"), + vm.uiState.value.searchResults + .map { it.session_id }, + ) + assertFalse(vm.uiState.value.searchHasMore) + assertFalse(vm.uiState.value.hasMore) + } + + @Test + fun `legacy search and non advancing page do not loop`() { + coEvery { mockApi.searchSessions("old", null, null, "cron", 20, 0) } returns + Response.success(SessionSearchResponse(listOf(SessionSearchResult("one")))) + coEvery { mockApi.searchSessions("new", null, null, "cron", 20, 0) } returns + Response.success(SessionSearchResponse(listOf(SessionSearchResult("two")), true, 0)) + val vm = createViewModel() + for (query in listOf("old", "new")) { + vm.setSearchQuery(query) + testDispatcher.scheduler.advanceUntilIdle() + assertFalse(vm.uiState.value.searchHasMore) + vm.loadMoreSearch() + } + coVerify(exactly = 2) { mockApi.searchSessions(any(), any(), any(), any(), any(), any()) } + } + @Test fun `blank query resets search mode`() { val vm = createViewModel() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt index 8c9d72be..3c0079bd 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -18,13 +18,17 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -75,6 +79,8 @@ class AppUpdateViewModelTest { every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit every { AuthManager.getDismissedUpdateTag() } returns null every { AuthManager.setDismissedUpdateTag(any()) } returns Unit + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false + every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit app = mockk(relaxed = true) every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir")) @@ -166,6 +172,21 @@ class AppUpdateViewModelTest { // ── Manual check ──────────────────────────────────────────────────── + @Test + fun checkForUpdate_stableReleasePromptsInstalledRc() = + runTest { + coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.25") + val vm = AppUpdateViewModel(app, checker, "1.25.rc.1", testDispatcher) + advanceUntilIdle() + + vm.checkForUpdate() + advanceUntilIdle() + + assertTrue(vm.state.value is AppUpdateState.UpdateAvailable) + assertEquals("v1.25", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag) + assertEquals(vm.state.value, AppUpdateCache.state.value) + } + @Test fun checkForUpdate_upToDateWhenSameVersion() = runTest { @@ -333,4 +354,244 @@ class AppUpdateViewModelTest { assertTrue(AppUpdateCache.dismissed) assertFalse(AppUpdateCache.isDialogVisible) } + + // ── Release candidate channel (opt-in) ────────────────────────────── + + @Test + fun init_checkUsesStableChannelByDefault() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + + coVerify(exactly = 1) { checker.fetchLatestRelease(false) } + coVerify(exactly = 0) { checker.fetchLatestRelease(true) } + assertFalse(vm.checkReleaseCandidateUpdates.value) + } + + @Test + fun init_doesNotAdoptCachedRcOfferOnStableChannel() = + runTest { + AppUpdateCache.update( + AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), + ) + + val vm = createViewModel() + advanceUntilIdle() + + coVerify(exactly = 1) { checker.fetchLatestRelease(false) } + assertEquals( + AppUpdateState.UpdateAvailable( + "v1.22.0", + "https://example.com/hermes-mobile-v1.22.0.apk", + 12345678L, + ), + vm.state.value, + ) + } + + @Test + fun init_adoptsCachedRcOfferWhenOptedIn() = + runTest { + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + AppUpdateCache.update( + AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), + ) + + val vm = createViewModel() + advanceUntilIdle() + + coVerify(exactly = 0) { checker.fetchLatestRelease(any()) } + assertEquals( + AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), + vm.state.value, + ) + assertTrue(vm.checkReleaseCandidateUpdates.value) + } + + @Test + fun setCheckReleaseCandidateUpdates_persistsAndRechecksOnRcChannel() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3") + + vm.setCheckReleaseCandidateUpdates(true) + advanceUntilIdle() + + assertTrue(vm.checkReleaseCandidateUpdates.value) + verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) } + coVerify(exactly = 1) { checker.fetchLatestRelease(true) } + assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag) + assertEquals(vm.state.value, AppUpdateCache.state.value) + } + + @Test + fun setCheckReleaseCandidateUpdates_sameValueDoesNotRecheckOrPersist() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + + vm.setCheckReleaseCandidateUpdates(false) + advanceUntilIdle() + + verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) } + coVerify(exactly = 1) { checker.fetchLatestRelease(false) } + } + + @Test + fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() = + runTest { + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3") + + val vm = createViewModel() + advanceUntilIdle() + assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag) + + // Back on the stable channel the installed version is current. + coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0") + vm.setCheckReleaseCandidateUpdates(false) + advanceUntilIdle() + + assertFalse(vm.checkReleaseCandidateUpdates.value) + assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value) + assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value) + // The RC offer must be gone everywhere, so startUpdate() is a no-op. + vm.startUpdate() + advanceUntilIdle() + coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) } + } + + @Test + fun checkForUpdate_afterOptInUsesRcChannel() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3") + + vm.setCheckReleaseCandidateUpdates(true) + advanceUntilIdle() + vm.checkForUpdate() + advanceUntilIdle() + + coVerify(exactly = 2) { checker.fetchLatestRelease(true) } + } + + // ── Channel-switch races ──────────────────────────────────────────── + // + // The real fetch bottoms out in a blocking OkHttp `execute()`, which cannot + // be interrupted by coroutine cancellation. `withContext(NonCancellable)` + // models that faithfully: the job is cancelled, the call still finishes, and + // its result must not be published. + + @Test + fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() = + runTest { + every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true + val rcGate = CompletableDeferred() + coEvery { checker.fetchLatestRelease(true) } coAnswers { + withContext(NonCancellable) { rcGate.await() } + updateInfo(tag = "v1.25.0-rc.3") + } + coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0") + + val vm = createViewModel() + advanceUntilIdle() + assertTrue("RC check must start in flight", vm.state.value is AppUpdateState.Checking) + + // User opts out while the RC check is still mid-request. + vm.setCheckReleaseCandidateUpdates(false) + // Let the un-interruptible RC request finish AFTER the channel switch. + rcGate.complete(Unit) + advanceUntilIdle() + + assertFalse(vm.checkReleaseCandidateUpdates.value) + assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value) + assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value) + verify(exactly = 1) { AuthManager.setLastKnownLatestTag("1.21.0") } + verify(exactly = 0) { AuthManager.setLastKnownLatestTag("v1.25.0-rc.3") } + + // And the stale RC offer must not be installable. + vm.startUpdate() + advanceUntilIdle() + coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) } + } + + @Test + fun cancelledCheck_neverSurfacesAnErrorState() = + runTest { + val gate = CompletableDeferred() + coEvery { checker.fetchLatestRelease(false) } coAnswers { + gate.await() + updateInfo() + } + coEvery { checker.fetchLatestRelease(true) } returns updateInfo() + + val vm = createViewModel() + val seen = mutableListOf() + backgroundScope.launch { vm.state.collect { seen += it } } + advanceUntilIdle() + assertTrue(vm.state.value is AppUpdateState.Checking) + + // Switching channels cancels the in-flight stable check. + vm.setCheckReleaseCandidateUpdates(true) + advanceUntilIdle() + + // A cancelled check must never be reported as a failed one — not even + // momentarily, because the next legitimate result would mask it. + assertTrue( + "cancellation must not emit an error state: $seen", + seen.none { it is AppUpdateState.Error }, + ) + assertEquals( + AppUpdateState.UpdateAvailable( + "v1.22.0", + "https://example.com/hermes-mobile-v1.22.0.apk", + 12345678L, + ), + vm.state.value, + ) + coVerify(exactly = 1) { checker.fetchLatestRelease(true) } + } + + @Test + fun startUpdate_refusesCachedRcOfferOnStableChannel() = + runTest { + coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0") + val vm = createViewModel() + advanceUntilIdle() + assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value) + + // A late RC result lands in the shared cache behind this ViewModel's + // back while the user is on the stable channel. + AppUpdateCache.update( + AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), + ) + + vm.startUpdate() + advanceUntilIdle() + + coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) } + // Refusal clears the stale offer and re-checks instead of dead-ending. + assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value) + assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value) + } + + @Test + fun resumeInstallAfterPermission_refusesStaleRcOffer() = + runTest { + coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0") + val vm = createViewModel() + advanceUntilIdle() + + AppUpdateCache.update( + AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), + ) + + vm.resumeInstallAfterPermission() + advanceUntilIdle() + + coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) } + verify(exactly = 0) { app.startActivity(any()) } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index bc42cec4..ce4b388e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -77,6 +77,8 @@ class SettingsViewModelTest { every { AuthManager.isUserMessageTokensEnabled() } returns true every { AuthManager.isAssistantMessageTokensEnabled() } returns true every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false + every { AuthManager.isKeepConnectedInBackground() } returns false every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" @@ -93,6 +95,8 @@ class SettingsViewModelTest { every { AuthManager.setUserMessageTokensEnabled(any()) } returns Unit every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit + every { AuthManager.setModelProviderShown(any()) } returns Unit + every { AuthManager.setKeepConnectedInBackground(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } answers { storedSelectedProfileId = firstArg() } @@ -327,4 +331,69 @@ class SettingsViewModelTest { verify { AuthManager.clearLastOpenedSessionIdsForConnection("prof-1") } } + + @Test + fun testShowModelProvider_loadsAndTogglesImmediately() = + runTest { + every { AuthManager.isModelProviderShown() } returns false + + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.showModelProvider) + + viewModel.onShowModelProviderChange(true) + assertEquals(true, viewModel.uiState.value.showModelProvider) + assertEquals(false, viewModel.uiState.value.isSaved) + verify { AuthManager.setModelProviderShown(true) } + + viewModel.onShowModelProviderChange(false) + assertEquals(false, viewModel.uiState.value.showModelProvider) + verify { AuthManager.setModelProviderShown(false) } + } + + @Test + fun testSave_persistsShowModelProvider() = + runTest { + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onShowModelProviderChange(true) + viewModel.save() + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.setModelProviderShown(true) } + assertEquals(true, viewModel.uiState.value.isSaved) + } + + @Test + fun testKeepConnectedInBackground_loadsAndToggles() = + runTest { + every { AuthManager.isKeepConnectedInBackground() } returns false + + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + + viewModel.onKeepConnectedInBackgroundChange(true) + assertEquals(true, viewModel.uiState.value.keepConnectedInBackground) + verify { AuthManager.setKeepConnectedInBackground(true) } + + viewModel.onKeepConnectedInBackgroundChange(false) + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + verify { AuthManager.setKeepConnectedInBackground(false) } + } + + @Test + fun testSave_persistsKeepConnectedInBackground() = + runTest { + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onKeepConnectedInBackgroundChange(true) + viewModel.save() + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.setKeepConnectedInBackground(true) } + assertEquals(true, viewModel.uiState.value.isSaved) + } } diff --git a/flake.nix b/flake.nix index 7564f81b..88a42a3a 100644 --- a/flake.nix +++ b/flake.nix @@ -93,6 +93,17 @@ # Writable gradle home mkdir -p "$GRADLE_USER_HOME" + # Enable host keyboard input for the project's existing AVD. + hermesAvdConfig="''${ANDROID_AVD_HOME:-''${ANDROID_USER_HOME:-$HOME/.android}/avd}/hermes_dev.avd/config.ini" + if [ -f "$hermesAvdConfig" ]; then + if ${pkgs.gnugrep}/bin/grep -q '^hw\.keyboard[[:space:]]*=' "$hermesAvdConfig"; then + ${pkgs.gnused}/bin/sed -i 's/^hw\.keyboard[[:space:]]*=.*/hw.keyboard=yes/' "$hermesAvdConfig" + else + printf '\nhw.keyboard=yes\n' >> "$hermesAvdConfig" + fi + fi + unset hermesAvdConfig + # ADB Screen Resolution Aliases alias avd-phone='adb shell wm size 1440x3120 && adb shell wm density 500' alias avd-phone-fhd='adb shell wm size 1080x2400 && adb shell wm density 420' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c8735f4..510dfb36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ androidGradlePlugin = "9.1.1" androidxBrowser = "1.10.0" androidxCore = "1.19.0" androidxLifecycle = "2.11.0" +androidxMedia3 = "1.11.1" androidxActivity = "1.13.0" androidxComposeBom = "2026.08.00" androidxTest = "1.7.0" @@ -91,6 +92,11 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +# Media (ExoPlayer Media3) +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidxMedia3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidxMedia3" } +media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidxMedia3" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }