From 67097c4d1e8e9d0c19d518a6143305ea66adc414 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:57:46 +0300 Subject: [PATCH] chore(release): promote dev to main for v1.29.0 (#1201) Co-authored-by: hy4ri --- .github/workflows/android.yml | 2 +- README.md | 15 + app/build.gradle.kts | 5 + .../8.json | 115 +++ app/src/androidTest/assets/player-tone.wav | Bin 0 -> 705678 bytes app/src/androidTest/assets/player-video.mp4 | Bin 0 -> 115151 bytes .../data/ws/StreamingEventDeviceTest.kt | 77 ++ .../ui/chat/ChatReadNotificationTest.kt | 500 +++++++++++ .../ui/chat/MediaViewerDeviceTest.kt | 229 +++++ .../components/ComposerInteractionTest.kt | 34 +- .../chat/components/ComposerReadinessTest.kt | 55 ++ .../ui/common/AppUpdatePrereleaseTest.kt | 47 + .../ui/sessions/SearchResultCardTest.kt | 62 ++ .../AboutSectionReleaseCandidateTest.kt | 97 +++ .../com/m57/hermescontrol/HermesControlApp.kt | 33 +- .../com/m57/hermescontrol/MainActivity.kt | 57 +- .../hermescontrol/data/config/ServerStore.kt | 25 +- .../data/config/ServerStoreState.kt | 5 + .../hermescontrol/data/local/AuthManager.kt | 291 +++++-- .../data/local/ChatMessageEntity.kt | 2 + .../data/local/ChatMessageMapper.kt | 2 + .../m57/hermescontrol/data/local/DataScope.kt | 45 + .../data/local/HermesDatabase.kt | 83 +- .../data/local/SessionListCacheStore.kt | 68 ++ .../m57/hermescontrol/data/local/SwrCache.kt | 63 ++ .../hermescontrol/data/model/Attachment.kt | 17 +- .../data/model/SessionSearchModels.kt | 11 +- .../remote/DashboardSessionTokenRefresher.kt | 85 +- .../data/remote/GatewayFileClient.kt | 1 + .../data/remote/HermesApiService.kt | 2 + .../data/remote/MediaDataSourceProvider.kt | 73 ++ .../data/remote/NetworkMonitor.kt | 90 ++ .../data/remote/NetworkResult.kt | 3 + .../data/session/ProfileSwitchCoordinator.kt | 19 +- .../data/update/AppUpdateChecker.kt | 161 +++- .../data/update/UpdateNoticeManager.kt | 19 +- .../m57/hermescontrol/data/ws/EventParser.kt | 36 +- .../hermescontrol/data/ws/HermesWsClient.kt | 168 +++- .../hermescontrol/data/ws/JsonRpcModels.kt | 7 + .../data/ws/KanbanEventsClient.kt | 156 ++-- .../data/ws/ModelOptionsRepository.kt | 50 ++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 11 +- .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../BackgroundConnectionController.kt | 115 +++ .../BackgroundConnectionPolicy.kt | 141 +++ .../notification/ChatNotificationService.kt | 209 ++++- .../notification/NotificationReplyReceiver.kt | 27 +- .../notification/ReplyNotificationTracker.kt | 302 +++++++ .../notification/TurnCorrelationTracker.kt | 384 +++++++++ .../notification/TurnRowResolver.kt | 93 ++ .../ui/achievements/AchievementsScreen.kt | 10 +- .../ui/achievements/AchievementsViewModel.kt | 61 +- .../hermescontrol/ui/bots/BotsViewModel.kt | 120 +-- .../ui/bots/group/GroupChatViewModel.kt | 5 + .../m57/hermescontrol/ui/chat/ChatBubble.kt | 22 +- .../hermescontrol/ui/chat/ChatInputPolicy.kt | 3 +- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 2 + .../ui/chat/ChatPersistenceRepository.kt | 12 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 71 +- .../ui/chat/ChatServerMessageMapper.kt | 89 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 355 +++++--- .../ui/chat/ChatWsEventReducer.kt | 103 ++- .../ui/chat/MediaExportHelper.kt | 241 ++++++ .../ui/chat/MediaStreamResolver.kt | 240 ++++++ .../m57/hermescontrol/ui/chat/MediaTypes.kt | 191 ++++- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 188 ++-- .../ui/chat/components/ChatComposer.kt | 23 +- .../chat/components/ChatLifecycleEffects.kt | 33 + .../ui/chat/components/ChatReadObserver.kt | 49 ++ .../ui/chat/components/ComposerToolbar.kt | 58 +- .../ui/chat/components/InlineMediaPlayer.kt | 211 +++++ .../ui/chat/components/InlineVideoPlayer.kt | 90 +- .../ui/chat/components/MediaViewerDialog.kt | 714 ++++++++++++++++ .../ui/chat/components/MessageCards.kt | 18 +- .../ui/chat/components/VideoViewerDialog.kt | 373 +------- .../hermescontrol/ui/chat/tool/ToolJson.kt | 23 + .../ui/chat/tool/ToolRenderer.kt | 3 + .../ui/chat/tool/ToolViewBuilder.kt | 8 +- .../ui/chat/tool/render/FileRenderers.kt | 2 +- .../ui/chat/tool/render/SkillRenderers.kt | 10 +- .../ui/chat/tool/render/TaskRenderers.kt | 7 +- .../hermescontrol/ui/common/ViewModelExt.kt | 72 +- .../hermescontrol/ui/config/ConfigScreen.kt | 2 +- .../ui/config/ConfigViewModel.kt | 130 ++- .../hermescontrol/ui/cron/CronJobsScreen.kt | 7 +- .../ui/cron/CronJobsViewModel.kt | 71 +- .../m57/hermescontrol/ui/files/FilesScreen.kt | 10 + .../hermescontrol/ui/files/FilesViewModel.kt | 53 +- .../hermescontrol/ui/gateway/GatewayScreen.kt | 7 +- .../ui/gateway/GatewayViewModel.kt | 19 +- .../ui/kanban/KanbanViewModel.kt | 104 ++- .../m57/hermescontrol/ui/keys/KeysScreen.kt | 7 +- .../hermescontrol/ui/keys/KeysViewModel.kt | 38 +- .../m57/hermescontrol/ui/logs/LogsScreen.kt | 7 +- .../hermescontrol/ui/logs/LogsViewModel.kt | 80 +- .../hermescontrol/ui/mcp/McpServersScreen.kt | 9 +- .../ui/mcp/McpServersViewModel.kt | 52 +- .../hermescontrol/ui/memory/MemoryScreen.kt | 2 +- .../ui/memory/MemoryViewModel.kt | 101 ++- .../m57/hermescontrol/ui/model/ModelScreen.kt | 65 +- .../hermescontrol/ui/model/ModelViewModel.kt | 18 +- .../hermescontrol/ui/pairing/PairingScreen.kt | 7 +- .../ui/pairing/PairingViewModel.kt | 25 +- .../hermescontrol/ui/plugins/PluginsScreen.kt | 13 +- .../ui/plugins/PluginsViewModel.kt | 93 +- .../ui/profiles/ProfilesScreen.kt | 7 +- .../ui/profiles/ProfilesViewModel.kt | 84 +- .../ui/providers/ProvidersScreen.kt | 9 +- .../ui/providers/ProvidersViewModel.kt | 37 +- .../ui/sessions/SessionSearchFormatting.kt | 104 ++- .../ui/sessions/SessionsScreen.kt | 33 +- .../ui/sessions/SessionsViewModel.kt | 288 +++++-- .../sessions/components/SearchResultCard.kt | 23 +- .../ui/settings/AppUpdateViewModel.kt | 186 +++- .../ui/settings/SettingsScreen.kt | 4 +- .../ui/settings/SettingsSubPages.kt | 7 + .../ui/settings/SettingsViewModel.kt | 20 + .../ui/settings/components/AboutSection.kt | 55 ++ .../settings/components/AppearanceSection.kt | 34 + .../ui/settings/components/BehaviorSection.kt | 35 + .../hermescontrol/ui/skills/SkillsScreen.kt | 7 +- .../ui/skills/SkillsViewModel.kt | 53 +- .../ui/system/SystemViewModel.kt | 106 ++- .../ui/toolsets/ToolsetsScreen.kt | 13 +- .../ui/toolsets/ToolsetsViewModel.kt | 33 +- .../ui/webhooks/WebhooksScreen.kt | 7 +- .../ui/webhooks/WebhooksViewModel.kt | 44 +- app/src/main/res/values-ar/strings.xml | 36 + app/src/main/res/values-ko/strings.xml | 38 +- app/src/main/res/values-zh/strings.xml | 36 + app/src/main/res/values/strings.xml | 38 +- .../m57/hermescontrol/E2eIntegrationTest.kt | 21 +- .../data/config/ServerStoreTest.kt | 40 + .../data/local/AuthManagerTest.kt | 121 ++- .../data/local/CacheScopeIsolationTest.kt | 371 ++++++++ .../hermescontrol/data/local/DataScopeTest.kt | 79 ++ .../local/HermesDatabaseMigrationsTest.kt | 12 + .../data/local/Issue647ProfileUrlTest.kt | 9 +- .../data/local/SessionListCacheStoreTest.kt | 130 +++ .../hermescontrol/data/local/SwrCacheTest.kt | 85 ++ .../DashboardSessionTokenRefresherTest.kt | 108 ++- .../data/remote/NetworkMonitorTest.kt | 151 +++- .../session/ProfileSwitchCoordinatorTest.kt | 51 +- .../update/AppUpdateCheckerNetworkTest.kt | 158 ++++ .../data/update/AppUpdateCheckerTest.kt | 219 ++++- .../data/update/UpdateNoticeManagerTest.kt | 104 +++ .../hermescontrol/data/ws/EventParserTest.kt | 11 +- .../data/ws/HermesWsClientTest.kt | 305 ++++++- .../data/ws/ModelOptionsRepositoryTest.kt | 96 +++ .../data/ws/StreamingEventParserTest.kt | 78 ++ .../BackgroundConnectionControllerTest.kt | 410 +++++++++ .../BackgroundConnectionPolicyTest.kt | 253 ++++++ .../NotificationReplyReceiverTest.kt | 21 + .../ReplyNotificationTrackerTest.kt | 253 ++++++ .../TurnCorrelationTrackerTest.kt | 411 +++++++++ .../ui/chat/ChatInputPolicyTest.kt | 21 +- .../ui/chat/ChatPersistenceRepositoryTest.kt | 36 + .../ChatReadNotificationIntegrationTest.kt | 296 +++++++ .../ui/chat/ChatUpdateCommandTest.kt | 1 + .../ui/chat/ChatViewModelTest.kt | 371 ++++++++ .../ui/chat/ChatWsEventReducerTest.kt | 116 +++ .../ui/chat/ComposerToolbarTest.kt | 42 +- .../ui/chat/MediaExportStreamingTest.kt | 599 +++++++++++++ .../chat/MediaPlaybackClassificationTest.kt | 42 + .../ReadNotificationReviewRegressionTest.kt | 807 ++++++++++++++++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 1 + .../chat/components/ChatReadObserverTest.kt | 198 +++++ .../chat/components/SyntaxHighlightingTest.kt | 29 + .../ui/chat/tool/ToolJsonTest.kt | 41 + .../ui/chat/tool/ToolViewBuilderTest.kt | 29 + .../chat/tool/ToolViewCharacterizationTest.kt | 71 ++ .../chat/tool/render/FileEditSupportTest.kt | 54 ++ .../ui/common/ViewModelExtTest.kt | 162 +++- .../sessions/SessionSearchFormattingTest.kt | 27 + .../ui/sessions/SessionsViewModelTest.kt | 121 +++ .../ui/settings/AppUpdateViewModelTest.kt | 261 ++++++ .../ui/settings/SettingsViewModelTest.kt | 69 ++ flake.nix | 11 + gradle/libs.versions.toml | 6 + 179 files changed, 14930 insertions(+), 1737 deletions(-) create mode 100644 app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/8.json create mode 100644 app/src/androidTest/assets/player-tone.wav create mode 100644 app/src/androidTest/assets/player-video.mp4 create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/data/ws/StreamingEventDeviceTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/AppUpdatePrereleaseTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SearchResultCardTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/DataScope.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/SessionListCacheStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/SwrCache.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/MediaDataSourceProvider.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/ReplyNotificationTracker.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/TurnCorrelationTracker.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/TurnRowResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MediaExportHelper.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MediaStreamResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/local/CacheScopeIsolationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/local/DataScopeTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/local/SessionListCacheStoreTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/local/SwrCacheTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/StreamingEventParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/ReplyNotificationTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/TurnCorrelationTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationIntegrationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaExportStreamingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaPlaybackClassificationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatReadObserverTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/render/FileEditSupportTest.kt 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 0000000000000000000000000000000000000000..38cd26329e2d35f1b79c41802654a825a07bb5e8 GIT binary patch literal 705678 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 + 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" }