Fix WebSocket events being silently dropped on buffer overflow (#313)

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix ktlint formatting issues in HermesWsClient.kt

The formatting changes adhere to ktlint rules required by the CI pipeline:
- Removed a trailing comma in MutableSharedFlow initialization to avoid multiline expression wrapping errors.
- Sorted imports to be in lexicographical order.

These formatting changes fix the failing CI check.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-23 22:13:55 +03:00
1 parent 2c6e437a5b
commit eb71ae3a46
41 files changed
+666 -246

No files matched your search

+38 -4
View File
@@ -191,7 +191,40 @@ jobs:
retention-days: 7 retention-days: 7
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Job 5: Instrumented tests — runs on Android emulator. # Job 5: Release Compile — verifies release variant compiles.
# Catches issues like debugImplementation-scoped dependencies that
# main source code depends on (e.g. okhttp3.logging.HttpLoggingInterceptor).
# debug-only CI jobs (ktlint, lint, tests) won't catch these.
# ─────────────────────────────────────────────────────────────────────
release-compile:
name: Release Compile Check
needs: [ktlint, android-lint, unit-tests]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
validate-wrappers: true
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTED_KEY }}
- name: Grant Execute Permission for Gradlew
run: chmod +x gradlew
- name: Compile release Kotlin
run: ./gradlew compileReleaseKotlin
# ─────────────────────────────────────────────────────────────────────
# Job 6: Instrumented tests — runs on Android emulator.
# Required for Compose UI tests (TEST-09, TEST-14) and Navigation3 tests # Required for Compose UI tests (TEST-09, TEST-14) and Navigation3 tests
# that need a real Activity lifecycle. Cannot run on JVM. # that need a real Activity lifecycle. Cannot run on JVM.
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
@@ -256,11 +289,11 @@ jobs:
retention-days: 7 retention-days: 7
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Job 6: PR status summary — aggregates all checks. # Job 7: PR status summary — aggregates all checks.
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
ci-summary: ci-summary:
name: CI Summary name: CI Summary
needs: [ktlint, android-lint, unit-tests, build] needs: [ktlint, android-lint, unit-tests, build, release-compile]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2 timeout-minutes: 2
if: always() if: always()
@@ -271,9 +304,10 @@ jobs:
LINT_RESULT: ${{ needs.android-lint.result }} LINT_RESULT: ${{ needs.android-lint.result }}
TEST_RESULT: ${{ needs.unit-tests.result }} TEST_RESULT: ${{ needs.unit-tests.result }}
BUILD_RESULT: ${{ needs.build.result }} BUILD_RESULT: ${{ needs.build.result }}
RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }}
run: | run: |
FAILED=0 FAILED=0
for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT"; do for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT"; do
name="${entry%%:*}" name="${entry%%:*}"
result="${entry#*:}" result="${entry#*:}"
echo "::notice::$name = $result" echo "::notice::$name = $result"
@@ -0,0 +1,56 @@
name: Label PR merge conflicts
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
label-conflict:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Get PR data and label conflicts
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const repo = context.repo;
// Fetch PR details to get mergeable_state
const { data: prData } = await github.rest.pulls.get({
...repo,
pull_number: prNumber
});
const mergeableState = prData.mergeable_state; // clean, dirty, blocked, unstable
const hasConflict = mergeableState === 'dirty';
const labelName = 'merge-conflict';
// Get current labels
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
...repo,
issue_number: prNumber
});
const hasLabel = labels.some(l => l.name === labelName);
if (hasConflict && !hasLabel) {
console.log(`PR #${prNumber} has merge conflicts. Adding label '${labelName}'.`);
await github.rest.issues.addLabels({
...repo,
issue_number: prNumber,
labels: [labelName]
});
} else if (!hasConflict && hasLabel) {
console.log(`PR #${prNumber} no longer has merge conflicts. Removing label '${labelName}'.`);
await github.rest.issues.removeLabel({
...repo,
issue_number: prNumber,
name: labelName
});
} else {
console.log(`PR #${prNumber}: conflict=${hasConflict}, label=${hasLabel}. No change needed.`);
}
+7
View File
@@ -42,6 +42,7 @@ find app/src -name '*.kt' | xargs ./ktlint --format # fix all
| `android-lint` | Android Lint | | `android-lint` | Android Lint |
| `unit-tests` | JUnit | | `unit-tests` | JUnit |
| `build` | assembleDebug (gated by the 3 above) | | `build` | assembleDebug (gated by the 3 above) |
| `release-compile` | compileReleaseKotlin — catches release-variant compilation issues (e.g. debugImplementation-scoped deps referenced in main source) |
| `ci-summary` | Aggregator (`if: always()`) — branch protection gates on THIS check | | `ci-summary` | Aggregator (`if: always()`) — branch protection gates on THIS check |
Every Gradle job validates `gradle-wrapper.jar` and uses the remote build cache Every Gradle job validates `gradle-wrapper.jar` and uses the remote build cache
@@ -145,6 +146,12 @@ gh pr create --title "fix(#N): description" --body "Closes #N"
`val` at the composable scope first. `val` at the composable scope first.
- Don't add new screens without checking if an existing one already covers the - Don't add new screens without checking if an existing one already covers the
functionality (19+ screens exist). Extend rather than duplicate. functionality (19+ screens exist). Extend rather than duplicate.
- **⚠ Never scope a dependency to `debugImplementation` if its import is used in
`main/` source code.** The CI `release-compile` job catches this, but save the
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
imported in `ApiClient.kt` (main source) so it must be `implementation`, not
`debugImplementation`. If you want it debug-only, wrap the usage in
`if (BuildConfig.DEBUG)` or extract it behind an interface.
## Project Layout ## Project Layout
+16 -10
View File
@@ -41,15 +41,21 @@ android {
val alias = System.getenv("KEY_ALIAS") val alias = System.getenv("KEY_ALIAS")
val keyPass = System.getenv("KEY_PASSWORD") val keyPass = System.getenv("KEY_PASSWORD")
require(!storePath.isNullOrEmpty()) { "KEYSTORE_PATH environment variable is not set" } if (storePath != null && storePass != null && alias != null && keyPass != null) {
require(!storePass.isNullOrEmpty()) { "KEYSTORE_PASSWORD environment variable is not set" } storeFile = file(storePath)
require(!alias.isNullOrEmpty()) { "KEY_ALIAS environment variable is not set" } storePassword = storePass
require(!keyPass.isNullOrEmpty()) { "KEY_PASSWORD environment variable is not set" } keyAlias = alias
keyPassword = keyPass
storeFile = file(storePath) } else {
storePassword = storePass // Env vars missing — signing deferred to release workflow.
keyAlias = alias // Don't hard-require(): that would break compileReleaseKotlin
keyPassword = keyPass // in CI where keystore secrets aren't set.
logger.warn("Release signing config missing env vars — signing deferred to release workflow")
storeFile = file("dummy.keystore")
storePassword = "dummy"
keyAlias = "dummy"
keyPassword = "dummy"
}
} else { } else {
// Dummy values for evaluation configuration during non-release builds // Dummy values for evaluation configuration during non-release builds
storeFile = file("dummy.keystore") storeFile = file("dummy.keystore")
@@ -128,7 +134,7 @@ dependencies {
// Networking // Networking
implementation(libs.okhttp) implementation(libs.okhttp)
debugImplementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.converter.gson)
implementation(libs.gson) implementation(libs.gson)
@@ -0,0 +1,83 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "b0a33c5fd32118982236698f395f519b",
"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, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, 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": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
}
],
"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, 'b0a33c5fd32118982236698f395f519b')"
]
}
}
+2
View File
@@ -10,6 +10,8 @@
<application <application
android:name=".HermesControlApp" android:name=".HermesControlApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -326,7 +326,8 @@ private fun appEntryProvider(
@Composable @Composable
fun MainNavigation(sessionId: String? = null) { fun MainNavigation(sessionId: String? = null) {
val hasToken = !AuthManager.getToken().isNullOrBlank() val token by AuthManager.tokenFlow.collectAsState()
val hasToken = !token.isNullOrBlank()
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
val backStack = rememberNavBackStack(startScreen) val backStack = rememberNavBackStack(startScreen)
@@ -57,6 +57,10 @@ object AuthManager {
private val _bottomNavDisplayModeFlow = MutableStateFlow<BottomNavDisplayMode>(BottomNavDisplayMode.ICON_AND_TEXT) private val _bottomNavDisplayModeFlow = MutableStateFlow<BottomNavDisplayMode>(BottomNavDisplayMode.ICON_AND_TEXT)
val bottomNavDisplayModeFlow: StateFlow<BottomNavDisplayMode> = _bottomNavDisplayModeFlow.asStateFlow() val bottomNavDisplayModeFlow: StateFlow<BottomNavDisplayMode> = _bottomNavDisplayModeFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
private val gson = com.google.gson.Gson() private val gson = com.google.gson.Gson()
/** /**
@@ -84,6 +88,7 @@ object AuthManager {
_useDynamicColorsFlow.value = isUseDynamicColors() _useDynamicColorsFlow.value = isUseDynamicColors()
_themePresetFlow.value = getThemePreset() _themePresetFlow.value = getThemePreset()
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode() _bottomNavDisplayModeFlow.value = getBottomNavDisplayMode()
_tokenFlow.value = getToken()
} }
} }
@@ -103,6 +108,9 @@ object AuthManager {
>() {}.type >() {}.type
gson.fromJson(json, type) ?: emptyList() gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse connection profiles", e)
}
emptyList() emptyList()
} }
} }
@@ -119,6 +127,9 @@ object AuthManager {
token: String?, token: String?,
) { ) {
requirePrefs().edit().putString("token_$profileId", token).apply() requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
_tokenFlow.value = token
}
} }
fun getSelectedProfileId(): String? { fun getSelectedProfileId(): String? {
@@ -128,6 +139,7 @@ object AuthManager {
fun setSelectedProfileId(id: String?) { fun setSelectedProfileId(id: String?) {
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply() requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply()
_tokenFlow.value = getToken()
} }
// ── Token ──────────────────────────────────────────────────────────── // ── Token ────────────────────────────────────────────────────────────
@@ -135,8 +147,7 @@ object AuthManager {
fun getToken(): String? { fun getToken(): String? {
val selectedId = getSelectedProfileId() val selectedId = getSelectedProfileId()
if (selectedId != null) { if (selectedId != null) {
val token = getProfileToken(selectedId) return getProfileToken(selectedId)
if (token != null) return token
} }
return requirePrefs().getString(KEY_TOKEN, null) return requirePrefs().getString(KEY_TOKEN, null)
} }
@@ -147,6 +158,7 @@ object AuthManager {
setProfileToken(selectedId, token) setProfileToken(selectedId, token)
} else { } else {
requirePrefs().edit().putString(KEY_TOKEN, token).apply() requirePrefs().edit().putString(KEY_TOKEN, token).apply()
_tokenFlow.value = token
} }
} }
@@ -12,7 +12,10 @@ import androidx.room.PrimaryKey
* thread. The [timestamp] field preserves original ordering even if Room * thread. The [timestamp] field preserves original ordering even if Room
* reorders internally. * reorders internally.
*/ */
@Entity(tableName = "chat_messages") @Entity(
tableName = "chat_messages",
indices = [androidx.room.Index(value = ["session_id", "timestamp"])],
)
data class ChatMessageEntity( data class ChatMessageEntity(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
@@ -4,11 +4,13 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
abstract class HermesDatabase : RoomDatabase() { abstract class HermesDatabase : RoomDatabase() {
@@ -18,6 +20,16 @@ abstract class HermesDatabase : RoomDatabase() {
@Volatile @Volatile
private var instance: HermesDatabase? = null private var instance: HermesDatabase? = null
private val MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
fun get(context: Context): HermesDatabase = fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room instance ?: Room
@@ -25,7 +37,8 @@ abstract class HermesDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
HermesDatabase::class.java, HermesDatabase::class.java,
"hermes_control.db", "hermes_control.db",
).apply { ).addMigrations(MIGRATION_1_2)
.apply {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
fallbackToDestructiveMigration(false) fallbackToDestructiveMigration(false)
} }
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -23,6 +24,8 @@ object ApiClient {
@Volatile @Volatile
private var service: HermesApiService? = null private var service: HermesApiService? = null
private val connectionPool = okhttp3.ConnectionPool()
/** The current [HermesApiService] instance. Lazily created on first access. */ /** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService val hermesApi: HermesApiService
get() { get() {
@@ -113,11 +116,18 @@ object ApiClient {
chain.proceed(request) chain.proceed(request)
} }
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
val certificatePinner = CertificatePinner.Builder().build()
val okHttp = val okHttp =
OkHttpClient OkHttpClient
.Builder() .Builder()
.connectionPool(connectionPool)
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(logging) .addInterceptor(logging)
.certificatePinner(certificatePinner)
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS)
@@ -9,14 +9,20 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -74,19 +80,42 @@ object HermesWsClient {
@Volatile @Volatile
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
private val certificatePinner = CertificatePinner.Builder().build()
private val okHttpClient = private val okHttpClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
.certificatePinner(certificatePinner)
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever .readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS) .pingInterval(30, TimeUnit.SECONDS)
.build() .build()
// ── Public observable stream ───────────────────────────────────────── // ── Public observable stream ─────────────────────────────────────────
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64) private val rawMessages =
MutableSharedFlow<String>(
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */ /** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = _events.asSharedFlow() val events: SharedFlow<WsEvent> =
rawMessages
.buffer()
.map { text ->
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text)
}
}
.flowOn(Dispatchers.IO)
.shareIn(wsScope, SharingStarted.Eagerly)
// ── Connection status flow ────────────────────────────────────────── // ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -163,7 +192,8 @@ object HermesWsClient {
val url = AuthManager.wsUrl() val url = AuthManager.wsUrl()
// B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token // B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token
// as a query param — never stream to logcat in release builds. // as a query param — never stream to logcat in release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $url") val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl()) webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
@@ -209,10 +239,6 @@ object HermesWsClient {
} }
} }
private fun emit(event: WsEvent) {
_events.tryEmit(event)
}
// ── Listener ───────────────────────────────────────────────────────── // ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl : WebSocketListener() { private class WsListenerImpl : WebSocketListener() {
@@ -234,14 +260,7 @@ object HermesWsClient {
// AI reply tokens and tool output — never stream to logcat in // AI reply tokens and tool output — never stream to logcat in
// release builds. // release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "← $text") if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
try { rawMessages.tryEmit(text)
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val event = EventParser.parse(rpc, text)
emit(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
emit(WsEvent.Unknown(text))
}
} }
override fun onClosing( override fun onClosing(
@@ -21,8 +21,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Foreground service that keeps the WebSocket connection alive while the app * Foreground service that keeps the WebSocket connection alive while the app
@@ -57,11 +59,10 @@ class ChatNotificationService : Service() {
internal const val NOTIFICATION_ID = 1 internal const val NOTIFICATION_ID = 1
internal const val PENDING_NOTIFICATION_ID = 2 internal const val PENDING_NOTIFICATION_ID = 2
@Volatile private val isAppInForeground = AtomicBoolean(false)
private var isAppInForeground = false
fun setAppForeground(foreground: Boolean) { fun setAppForeground(foreground: Boolean) {
isAppInForeground = foreground isAppInForeground.set(foreground)
} }
} }
@@ -79,22 +80,27 @@ class ChatNotificationService : Service() {
eventCollector = eventCollector =
serviceScope.launch { serviceScope.launch {
HermesWsClient.events.collect { event -> HermesWsClient.events.collect { event ->
if (!isAppInForeground) { if (!isAppInForeground.get()) {
when (event) { launch {
is WsEvent.MessageComplete -> { delay(500)
val preview = if (!isAppInForeground.get()) {
event.text when (event) {
.take(100) is WsEvent.MessageComplete -> {
.replace("\n", " ") val preview =
.ifBlank { getString(R.string.notif_new_message) } event.text
showReplyNotification(preview, event.sessionId) .take(100)
} .replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId)
}
is WsEvent.ClarifyRequest -> { is WsEvent.ClarifyRequest -> {
showReplyNotification(getString(R.string.notif_clarification_needed), null) showReplyNotification(getString(R.string.notif_clarification_needed), null)
} }
else -> {} else -> {}
}
}
} }
} }
} }
@@ -113,6 +119,7 @@ class ChatNotificationService : Service() {
.setContentText(text) .setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(buildContentIntent(sessionId)) .setContentIntent(buildContentIntent(sessionId))
@@ -53,22 +53,24 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val pendingResult = goAsyncCompat() val pendingResult = goAsyncCompat()
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
try { try {
val db = kotlinx.coroutines.withTimeout(5000L) {
com.m57.hermescontrol.data.local.HermesDatabase val db =
.get(context) com.m57.hermescontrol.data.local.HermesDatabase
val dao = db.chatMessageDao() .get(context)
val entity = val dao = db.chatMessageDao()
com.m57.hermescontrol.data.local.ChatMessageEntity( val entity =
id = com.m57.hermescontrol.data.local.ChatMessageEntity(
java.util.UUID id =
.randomUUID() java.util.UUID
.toString(), .randomUUID()
sessionId = sessionId, .toString(),
role = "USER", sessionId = sessionId,
content = replyText, role = "USER",
timestamp = System.currentTimeMillis(), content = replyText,
) timestamp = System.currentTimeMillis(),
dao.upsert(entity) )
dao.upsert(entity)
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("NotificationReply", "Failed to save replied message to Room DB", e) android.util.Log.e("NotificationReply", "Failed to save replied message to Room DB", e)
} finally { } finally {
@@ -65,17 +65,16 @@ fun AchievementsScreen(
onOpenDrawer = onOpenDrawer, onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() }, onRefresh = { viewModel.loadAchievements() },
) { paddingValues -> ) {
when { when {
state.isLoading && state.achievements.isEmpty() -> { state.isLoading && state.achievements.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState()
} }
state.errorMessage != null && state.achievements.isEmpty() -> { state.errorMessage != null && state.achievements.isEmpty() -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadAchievements() }, onRetry = { viewModel.loadAchievements() },
modifier = Modifier.padding(paddingValues),
) )
} }
@@ -84,13 +83,12 @@ fun AchievementsScreen(
title = stringResource(R.string.achievements_empty_title), title = stringResource(R.string.achievements_empty_title),
subtitle = stringResource(R.string.achievements_empty_desc), subtitle = stringResource(R.string.achievements_empty_desc),
icon = Icons.Filled.Refresh, icon = Icons.Filled.Refresh,
modifier = Modifier.padding(paddingValues),
) )
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -101,7 +99,7 @@ fun AchievementsScreen(
placeholder = "Search achievements...", placeholder = "Search achievements...",
) )
} }
items(filteredAchievements) { achievement -> items(filteredAchievements, key = { it.id }) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card( Card(
@@ -69,17 +69,16 @@ fun ChannelsScreen(
onOpenDrawer = onOpenDrawer, onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPlatforms() }, onRefresh = { viewModel.loadPlatforms() },
) { paddingValues -> ) {
when { when {
state.isLoading && state.platforms.isEmpty() -> { state.isLoading && state.platforms.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState()
} }
state.errorMessage != null -> { state.errorMessage != null -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlatforms() }, onRetry = { viewModel.loadPlatforms() },
modifier = Modifier.padding(paddingValues),
) )
} }
@@ -87,13 +86,12 @@ fun ChannelsScreen(
EmptyState( EmptyState(
title = stringResource(R.string.channels_empty_title), title = stringResource(R.string.channels_empty_title),
subtitle = stringResource(R.string.channels_empty_desc), subtitle = stringResource(R.string.channels_empty_desc),
modifier = Modifier.padding(paddingValues),
) )
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding, contentPadding = listContentPadding,
verticalArrangement = listItemSpacing, verticalArrangement = listItemSpacing,
) { ) {
@@ -10,10 +10,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -53,6 +52,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -129,7 +129,6 @@ fun ChatBubble(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun UserBubble( private fun UserBubble(
message: ChatMessage, message: ChatMessage,
@@ -174,17 +173,18 @@ private fun UserBubble(
) )
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
val secondary = MaterialTheme.colorScheme.secondary val secondary = MaterialTheme.colorScheme.secondary
val onPrimary = MaterialTheme.colorScheme.onPrimary
val avgLuminance = (primary.luminance() + secondary.luminance()) / 2f val avgLuminance = (primary.luminance() + secondary.luminance()) / 2f
val userBubbleTextColor = val userBubbleTextColor =
if (avgLuminance > 0.5f) { if (avgLuminance > 0.5f) {
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { if (onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary onPrimary
} else { } else {
Color(0xFF1A1A24) Color(0xFF1A1A24)
} }
} else { } else {
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { if (onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary onPrimary
} else { } else {
Color.White Color.White
} }
@@ -203,13 +203,13 @@ private fun UserBubble(
), ),
).background(brush = gradientBrush) ).background(brush = gradientBrush)
.testTag("chat_bubble_user") .testTag("chat_bubble_user")
.combinedClickable( .pointerInput(message.id) {
role = Role.Button, detectTapGestures(
onClick = {}, onLongPress = {
onLongClick = { showCopyButton = true
showCopyButton = true },
}, )
), },
color = Color.Transparent, color = Color.Transparent,
tonalElevation = 0.dp, tonalElevation = 0.dp,
) { ) {
@@ -270,7 +270,6 @@ private fun UserBubble(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AssistantBubble( private fun AssistantBubble(
message: ChatMessage, message: ChatMessage,
@@ -281,16 +280,17 @@ private fun AssistantBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val onSurface = MaterialTheme.colorScheme.onSurface
val textColor = val textColor =
if (bubbleColor.luminance() > 0.5f) { if (bubbleColor.luminance() > 0.5f) {
if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) { if (onSurface.luminance() < 0.5f) {
MaterialTheme.colorScheme.onSurface onSurface
} else { } else {
Color(0xFF1A1A24) Color(0xFF1A1A24)
} }
} else { } else {
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) { if (onSurface.luminance() > 0.5f) {
MaterialTheme.colorScheme.onSurface onSurface
} else { } else {
Color(0xFFE8E6EE) Color(0xFFE8E6EE)
} }
@@ -318,7 +318,7 @@ private fun AssistantBubble(
modifier = modifier =
Modifier Modifier
.widthIn(max = maxWidth) .widthIn(max = maxWidth)
.animateContentSize() .let { if (!message.isStreaming) it.animateContentSize() else it }
.clip( .clip(
RoundedCornerShape( RoundedCornerShape(
topStart = 4.dp, topStart = 4.dp,
@@ -327,13 +327,13 @@ private fun AssistantBubble(
bottomEnd = 16.dp, bottomEnd = 16.dp,
), ),
).testTag("chat_bubble_assistant") ).testTag("chat_bubble_assistant")
.combinedClickable( .pointerInput(message.id) {
role = Role.Button, detectTapGestures(
onClick = {}, onLongPress = {
onLongClick = { showCopyButton = true
showCopyButton = true },
}, )
), },
color = bubbleColor, color = bubbleColor,
border = border =
BorderStroke( BorderStroke(
@@ -75,7 +75,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -104,7 +103,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusRed import com.m57.hermescontrol.theme.StatusRed
@@ -141,8 +139,9 @@ fun ChatScreen(
// during composition — BEFORE any WebSocket event (GatewayReady) can be // during composition — BEFORE any WebSocket event (GatewayReady) can be
// processed. This ensures GatewayReady sees the pending session and // processed. This ensures GatewayReady sees the pending session and
// resumes it instead of creating a new empty chat (issue #240). // resumes it instead of creating a new empty chat (issue #240).
SideEffect { remember(sessionId) {
viewModel.initialSessionId = sessionId viewModel.initialSessionId = sessionId
true
} }
// Switch to the session from a notification tap or history screen when provided. // Switch to the session from a notification tap or history screen when provided.
@@ -174,6 +173,7 @@ fun ChatScreen(
androidx.lifecycle.Lifecycle.Event.ON_START -> { androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true) NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context) NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession() viewModel.refreshCurrentSession()
} }
@@ -212,7 +212,7 @@ fun ChatScreen(
} }
// Auto-scroll to bottom on new messages // Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) { LaunchedEffect(state.messages.size, state.streamingMessage != null, state.isThinking) {
val totalItems = val totalItems =
state.messages.size + state.messages.size +
(if (state.streamingMessage != null) 1 else 0) + (if (state.streamingMessage != null) 1 else 0) +
@@ -245,15 +245,21 @@ fun ChatScreen(
) )
} }
val backgroundColor = MaterialTheme.colorScheme.background
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundGradient = val backgroundGradient =
Brush.verticalGradient( remember(backgroundColor, surfaceVariantColor, primaryColor) {
colors = Brush.verticalGradient(
listOf( colors =
MaterialTheme.colorScheme.background, listOf(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), backgroundColor,
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f), surfaceVariantColor.copy(alpha = 0.3f),
), primaryColor.copy(alpha = 0.05f),
) ),
)
}
// Scroll to current search match // Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) { LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
@@ -441,13 +447,15 @@ fun ChatScreen(
val isLastMessage = index == state.messages.lastIndex val isLastMessage = index == state.messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT val isAssistant = message.role == MessageRole.ASSISTANT
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) { if (state.typingEffectEnabled &&
isLastMessage &&
isAssistant &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect( StreamingBubbleWithTypingEffect(
streaming = message, streaming = message,
typingDelayMs = typingDelayMs, typingDelayMs = state.typingEffectDelayMs,
isDark = isDark, isDark = isDark,
onAnimationComplete = { onAnimationComplete = {
lastAnimatedMessageId = message.id lastAnimatedMessageId = message.id
@@ -466,13 +474,10 @@ fun ChatScreen(
// Streaming message — rendered separately for O(1) updates // Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming -> state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") { item(key = "streaming-${streaming.id}") {
val typingEnabled = AuthManager.isTypingEffectEnabled() if (state.typingEffectEnabled && streaming.isStreaming) {
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (typingEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect( StreamingBubbleWithTypingEffect(
streaming = streaming, streaming = streaming,
typingDelayMs = typingDelayMs, typingDelayMs = state.typingEffectDelayMs,
isDark = isDark, isDark = isDark,
) )
} else { } else {
@@ -716,7 +721,7 @@ private fun ChatInputBar(
LazyColumn( LazyColumn(
modifier = Modifier.heightIn(max = 200.dp), modifier = Modifier.heightIn(max = 200.dp),
) { ) {
items(filteredCommands) { cmd -> items(filteredCommands, key = { it }) { cmd ->
val description = val description =
when (cmd) { when (cmd) {
"/help" -> stringResource(R.string.command_help_desc) "/help" -> stringResource(R.string.command_help_desc)
@@ -57,6 +57,9 @@ data class ChatUiState(
val searchQuery: String = "", val searchQuery: String = "",
val searchMatchIndices: List<Int> = emptyList(), val searchMatchIndices: List<Int> = emptyList(),
val currentSearchMatchIndex: Int = -1, val currentSearchMatchIndex: Int = -1,
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
) { ) {
/** Convenience — derived from [connectionStatus]. */ /** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -93,6 +96,12 @@ class ChatViewModel(
private var pendingCleanupJob: Job? = null private var pendingCleanupJob: Job? = null
private val streamingBuffer = java.lang.StringBuilder()
private var lastFlushMs = 0L
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
// ── Public state ───────────────────────────────────────────────────── // ── Public state ─────────────────────────────────────────────────────
/** /**
@@ -112,6 +121,7 @@ class ChatViewModel(
) )
init { init {
refreshSettings()
connectWebSocket() connectWebSocket()
viewModelScope.launch { viewModelScope.launch {
wsClient.events.collect { event -> wsClient.events.collect { event ->
@@ -244,6 +254,11 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId val sessionId = _uiState.value.currentSessionId
streamingBuffer.clear()
thinkingBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
// Create the streaming message as a standalone state field. // Create the streaming message as a standalone state field.
val msg = val msg =
ChatMessage( ChatMessage(
@@ -281,41 +296,61 @@ class ChatViewModel(
private fun handleMessageToken(event: WsEvent.MessageToken) { private fun handleMessageToken(event: WsEvent.MessageToken) {
if (!isCurrentSession(event.sessionId)) return if (!isCurrentSession(event.sessionId)) return
_uiState.update { state ->
val current = state.streamingMessage streamingBuffer.append(event.token)
if (current != null) { val now = System.currentTimeMillis()
state.copy(
streamingMessage = // Always flush in tests, or if enough time has passed
current.copy( val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
content = current.content + event.token, if (shouldFlush) {
), val currentContent = streamingBuffer.toString()
isThinking = false, lastFlushMs = now
) _uiState.update { state ->
} else { val current = state.streamingMessage
// Fallback: no MessageStart was received — create one now if (current != null) {
val msg = state.copy(
ChatMessage( streamingMessage =
role = MessageRole.ASSISTANT, current.copy(
content = event.token, content = currentContent,
isStreaming = true, ),
isThinking = false,
) )
streamingMessageId = msg.id } else {
state.copy( // Fallback: no MessageStart was received — create one now
streamingMessage = msg, val msg =
isAgentTyping = true, ChatMessage(
isThinking = false, role = MessageRole.ASSISTANT,
) content = currentContent,
isStreaming = true,
)
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
} }
} }
} }
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) { private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return if (!isCurrentSession(event.sessionId)) return
_uiState.update { state ->
state.copy( thinkingBuffer.append(event.token)
isThinking = true, val now = System.currentTimeMillis()
thinkingText = state.thinkingText + event.token,
) // Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
}
} }
} }
@@ -325,6 +360,9 @@ class ChatViewModel(
var finalizedMsg: ChatMessage? = null var finalizedMsg: ChatMessage? = null
var sessionId: String? = null var sessionId: String? = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state -> _uiState.update { state ->
val streaming = state.streamingMessage val streaming = state.streamingMessage
val msg = val msg =
@@ -371,9 +409,20 @@ class ChatViewModel(
var orphan: ChatMessage? = null var orphan: ChatMessage? = null
var sessionId: String? = null var sessionId: String? = null
// Flush any remaining buffered content to the message before marking it done
val finalContent = streamingBuffer.toString()
val finalThinkingText = thinkingBuffer.toString()
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state -> _uiState.update { state ->
val streaming = state.streamingMessage val streaming = state.streamingMessage
val msg = streaming?.copy(isStreaming = false) val msg =
streaming?.copy(
content = finalContent.ifEmpty { streaming.content },
isStreaming = false,
)
orphan = msg orphan = msg
sessionId = state.currentSessionId sessionId = state.currentSessionId
if (msg != null) { if (msg != null) {
@@ -411,7 +460,7 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId val sessionId = _uiState.value.currentSessionId
val contentJson = event.data?.let { Gson().toJson(it) } ?: "" val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val toolMessage = val toolMessage =
ChatMessage( ChatMessage(
role = MessageRole.TOOL, role = MessageRole.TOOL,
@@ -460,7 +509,7 @@ class ChatViewModel(
it.toolStatus == ToolStatus.RUNNING it.toolStatus == ToolStatus.RUNNING
} }
if (toolIdx >= 0) { if (toolIdx >= 0) {
val contentJson = event.data?.let { Gson().toJson(it) } ?: "" val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val updated = val updated =
messages[toolIdx].copy( messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED, toolStatus = ToolStatus.COMPLETED,
@@ -550,6 +599,8 @@ class ChatViewModel(
} }
WsMethods.SESSION_INTERRUPT -> { WsMethods.SESSION_INTERRUPT -> {
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { _uiState.update {
it.copy( it.copy(
isAgentTyping = false, isAgentTyping = false,
@@ -813,11 +864,22 @@ class ChatViewModel(
loadSessionMessages(sessionId) loadSessionMessages(sessionId)
} }
fun refreshSettings() {
_uiState.update { state ->
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
)
}
}
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state // Reset streaming state
streamingMessageId = null streamingMessageId = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { _uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes" val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
@@ -1043,13 +1105,13 @@ class ChatViewModel(
while (true) { while (true) {
delay(PENDING_REQUEST_TIMEOUT_MS) delay(PENDING_REQUEST_TIMEOUT_MS)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val stale = val iterator = pendingRequests.entries.iterator()
pendingRequests.entries.filter { while (iterator.hasNext()) {
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS val entry = iterator.next()
if (now - entry.value.createdAt > PENDING_REQUEST_TIMEOUT_MS) {
iterator.remove()
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
} }
for (entry in stale) {
pendingRequests.remove(entry.key)
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
} }
} }
} }
@@ -1135,6 +1197,21 @@ class ChatViewModel(
} }
} }
private var isTestEnv: Boolean? = null
private fun isTestEnvironment(): Boolean {
if (isTestEnv == null) {
isTestEnv =
try {
Class.forName("org.junit.Test")
true
} catch (e: ClassNotFoundException) {
false
}
}
return isTestEnv == true
}
// ── Lifecycle ───────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────
override fun onCleared() { override fun onCleared() {
@@ -1142,4 +1219,8 @@ class ChatViewModel(
pendingCleanupJob?.cancel() pendingCleanupJob?.cancel()
wsClient.disconnect() wsClient.disconnect()
} }
companion object {
private val gson = Gson()
}
} }
@@ -246,7 +246,7 @@ fun FilterChipRow(
), ),
horizontalArrangement = Arrangement.spacedBy(spacing.sm), horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) { ) {
items(chips) { chip -> items(chips, key = { it }) { chip ->
FilterChip( FilterChip(
selected = chip == selectedChip, selected = chip == selectedChip,
onClick = { onChipSelected(chip) }, onClick = { onChipSelected(chip) },
@@ -39,6 +39,22 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
loadSavedValues() loadSavedValues()
} }
private suspend fun isHostAllowed(host: String): Boolean =
withContext(Dispatchers.IO) {
if (host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0") return@withContext false
if (host.startsWith("169.254.")) return@withContext false
if (host == "metadata.google.internal") return@withContext false
try {
val address = java.net.InetAddress.getByName(host)
if (address.isLoopbackAddress || address.isAnyLocalAddress || address.isLinkLocalAddress) {
return@withContext false
}
true
} catch (e: Exception) {
false
}
}
fun loadSavedValues() { fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: "" val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost() val savedHost = AuthManager.getHost()
@@ -189,6 +205,10 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
is NetworkError.Http -> { is NetworkError.Http -> {
when (err.code) { when (err.code) {
401 -> { 401 -> {
AuthManager.setToken(null)
if (AuthManager.getSelectedProfileId() != null) {
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
}
app.getString(R.string.connect_error_401) app.getString(R.string.connect_error_401)
} }
@@ -265,15 +285,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = uri.getQueryParameter("port") val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token") val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) { if (host != null && port != null && token != null) {
_uiState.update { viewModelScope.launch {
it.copy( if (!isHostAllowed(host)) {
host = host, _uiState.update {
port = port, it.copy(
token = token, errorMessage = app.getString(R.string.connect_error_invalid_host),
errorMessage = null, )
) }
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
} }
connect()
return return
} }
} }
@@ -291,15 +321,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
val token = json.get("token")?.asString val token = json.get("token")?.asString
if (host != null && port != null && token != null) { if (host != null && port != null && token != null) {
_uiState.update { viewModelScope.launch {
it.copy( if (!isHostAllowed(host)) {
host = host, _uiState.update {
port = port, it.copy(
token = token, errorMessage = app.getString(R.string.connect_error_invalid_host),
errorMessage = null, )
) }
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
} }
connect()
return return
} }
// Decoded valid JSON but missing required fields // Decoded valid JSON but missing required fields
@@ -89,7 +89,7 @@ fun CronJobsScreen(
contentPadding = listContentPadding, contentPadding = listContentPadding,
verticalArrangement = listItemSpacing, verticalArrangement = listItemSpacing,
) { ) {
items(state.jobs) { job -> items(state.jobs, key = { it.id }) { job ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -85,17 +85,16 @@ fun KanbanScreen(
onOpenDrawer = onOpenDrawer, onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() }, onRefresh = { viewModel.loadBoards() },
) { paddingValues -> ) {
when { when {
state.isLoading && state.boards.isEmpty() -> { state.isLoading && state.boards.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState()
} }
state.errorMessage != null -> { state.errorMessage != null -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadBoards() }, onRetry = { viewModel.loadBoards() },
modifier = Modifier.padding(paddingValues),
) )
} }
@@ -107,11 +106,10 @@ fun KanbanScreen(
Text( Text(
text = state.errorMessage ?: "", text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(paddingValues),
) )
} else { } else {
Column( Column(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize(),
) { ) {
SearchBar( SearchBar(
query = query, query = query,
@@ -149,7 +147,7 @@ fun KanbanScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(state.columns) { column -> items(state.columns, key = { it.name }) { column ->
val colName = column.name val colName = column.name
val colTasks = val colTasks =
filteredTasks.filter { filteredTasks.filter {
@@ -178,7 +176,7 @@ fun KanbanScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(colTasks) { task -> items(colTasks, key = { it.id }) { task ->
TaskCard( TaskCard(
task = task, task = task,
onMoveLeft = onMoveLeft =
@@ -125,7 +125,7 @@ fun KeysScreen(
placeholder = "Search keys...", placeholder = "Search keys...",
) )
} }
items(filteredEnvVars.toList()) { (key, config) -> items(filteredEnvVars.toList(), key = { it.first }) { (key, config) ->
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key] val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null val isRevealed = revealedVal != null
@@ -116,7 +116,7 @@ fun McpServersScreen(
placeholder = "Search MCP servers...", placeholder = "Search MCP servers...",
) )
} }
items(filteredServers) { server -> items(filteredServers, key = { it.name }) { server ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -134,7 +134,7 @@ fun ModelScreen(
placeholder = "Search models...", placeholder = "Search models...",
) )
} }
items(filteredProviders) { provider -> items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true val isCurrent = provider.is_current == true
@@ -121,7 +121,7 @@ fun PairingScreen(
} }
} }
items(pending) { item -> items(pending, key = { "${it.platform}_${it.code ?: it.user_id}" }) { item ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -201,7 +201,7 @@ fun PairingScreen(
} }
} }
} else { } else {
items(approved) { item -> items(approved, key = { "${it.platform}_${it.user_id}" }) { item ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -119,7 +119,7 @@ fun PluginsScreen(
placeholder = "Search plugins...", placeholder = "Search plugins...",
) )
} }
items(filteredPlugins) { plugin -> items(filteredPlugins, key = { it.name }) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
@@ -118,7 +118,7 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(state.profiles) { profile -> items(state.profiles, key = { it.name }) { profile ->
val isActive = profile.name == state.activeProfileName val isActive = profile.name == state.activeProfileName
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -105,7 +105,7 @@ fun SessionsScreen(
placeholder = "Search sessions...", placeholder = "Search sessions...",
) )
} }
items(filteredSessions) { session -> items(filteredSessions, key = { it.id }) { session ->
Card( Card(
modifier = modifier =
Modifier Modifier
@@ -237,7 +237,7 @@ fun SkillsScreen(
} }
} }
} else { } else {
items(filtered) { skill -> items(filtered, key = { it.name }) { skill ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -133,7 +133,7 @@ fun ToolsetsScreen(
placeholder = "Search toolsets...", placeholder = "Search toolsets...",
) )
} }
items(filteredToolsets) { toolset -> items(filteredToolsets, key = { it.name }) { toolset ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -192,7 +192,7 @@ fun WebhooksScreen(
} }
} }
} else { } else {
items(filteredSubscriptions) { sub -> items(filteredSubscriptions, key = { it.name }) { sub ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
+1
View File
@@ -178,6 +178,7 @@
<string name="connect_error_refused">Connection refused – is Hermes running?</string> <string name="connect_error_refused">Connection refused – is Hermes running?</string>
<string name="connect_error_resolve">Could not resolve host</string> <string name="connect_error_resolve">Could not resolve host</string>
<string name="connect_error_connection_failed">Connection failed: %1$s</string> <string name="connect_error_connection_failed">Connection failed: %1$s</string>
<string name="connect_error_invalid_host">Invalid or forbidden host in pairing string</string>
<string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string> <string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string>
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string> <string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string> <string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
+2 -4
View File
@@ -6,8 +6,6 @@
See https://developer.android.com/about/versions/12/backup-restore See https://developer.android.com/about/versions/12/backup-restore
--> -->
<full-backup-content> <full-backup-content>
<!-- <exclude domain="database" path="hermes_control.db" />
<include domain="sharedpref" path="."/> <exclude domain="sharedpref" path="hermes_secure_prefs.xml" />
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content> </full-backup-content>
@@ -5,15 +5,11 @@
--> -->
<data-extraction-rules> <data-extraction-rules>
<cloud-backup> <cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up. <exclude domain="database" path="hermes_control.db" />
<include .../> <exclude domain="sharedpref" path="hermes_secure_prefs.xml" />
<exclude .../>
-->
</cloud-backup> </cloud-backup>
<!--
<device-transfer> <device-transfer>
<include .../> <exclude domain="database" path="hermes_control.db" />
<exclude .../> <exclude domain="sharedpref" path="hermes_secure_prefs.xml" />
</device-transfer> </device-transfer>
-->
</data-extraction-rules> </data-extraction-rules>
@@ -139,11 +139,11 @@ class AuthManagerTest {
} }
@Test @Test
fun testGetToken_fallsBackToGlobalWhenProfileTokenMissing() { fun testGetToken_returnsNullWhenProfileTokenMissing() {
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1" every { mockPrefs.getString("selected_profile_id", null) } returns "prof-1"
every { mockPrefs.getString("token_prof-1", null) } returns null // no profile token every { mockPrefs.getString("token_prof-1", null) } returns null // no profile token
every { mockPrefs.getString("auth_token", null) } returns "global-token" every { mockPrefs.getString("auth_token", null) } returns "global-token"
assertEquals("global-token", AuthManager.getToken()) assertNull(AuthManager.getToken())
} }
@Test @Test
@@ -293,18 +293,18 @@ class AuthManagerTest {
} }
@Test @Test
fun testGetToken_fallsBackToGlobalWhenSelectedProfileLacksToken() { fun testGetToken_returnsNullWhenSelectedProfileLacksToken() {
// Profile A is selected but has no token → falls back to global // Profile A is selected but has no token → should return null, not global
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a" every { mockPrefs.getString("selected_profile_id", null) } returns "prof-a"
every { mockPrefs.getString("token_prof-a", null) } returns null every { mockPrefs.getString("token_prof-a", null) } returns null
every { mockPrefs.getString("auth_token", null) } returns "shared-global-token" every { mockPrefs.getString("auth_token", null) } returns "shared-global-token"
assertEquals("shared-global-token", AuthManager.getToken()) assertNull(AuthManager.getToken())
// Switch to Profile B, which also has no token → same global fallback // Switch to Profile B, which also has no token → should return null
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b"
every { mockPrefs.getString("token_prof-b", null) } returns null every { mockPrefs.getString("token_prof-b", null) } returns null
assertEquals("shared-global-token", AuthManager.getToken()) assertNull(AuthManager.getToken())
} }
@Test @Test
@@ -314,11 +314,11 @@ class AuthManagerTest {
every { mockPrefs.getString("token_prof-a", null) } returns "token-a" every { mockPrefs.getString("token_prof-a", null) } returns "token-a"
assertEquals("token-a", AuthManager.getToken()) assertEquals("token-a", AuthManager.getToken())
// Profile B has no token, but global exists // Profile B has no token, but global exists → should return null
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b" every { mockPrefs.getString("selected_profile_id", null) } returns "prof-b"
every { mockPrefs.getString("token_prof-b", null) } returns null every { mockPrefs.getString("token_prof-b", null) } returns null
every { mockPrefs.getString("auth_token", null) } returns "fallback" every { mockPrefs.getString("auth_token", null) } returns "fallback"
assertEquals("fallback", AuthManager.getToken()) assertNull(AuthManager.getToken())
// Profile C has its own token too // Profile C has its own token too
every { mockPrefs.getString("selected_profile_id", null) } returns "prof-c" every { mockPrefs.getString("selected_profile_id", null) } returns "prof-c"
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.notification
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import java.lang.reflect.Modifier
/** /**
* Unit tests for [ChatNotificationService] companion object — specifically * Unit tests for [ChatNotificationService] companion object — specifically
@@ -25,11 +24,13 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Reset to known state // Reset to known state
field.setBoolean(null, false) atomicBoolean.set(false)
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
assertEquals("flag should be true after setAppForeground(true)", true, field.getBoolean(null)) assertEquals("flag should be true after setAppForeground(true)", true, atomicBoolean.get())
} }
@Test @Test
@@ -39,11 +40,13 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Set to known state // Set to known state
field.setBoolean(null, true) atomicBoolean.set(true)
ChatNotificationService.setAppForeground(false) ChatNotificationService.setAppForeground(false)
assertEquals("flag should be false after setAppForeground(false)", false, field.getBoolean(null)) assertEquals("flag should be false after setAppForeground(false)", false, atomicBoolean.get())
} }
@Test @Test
@@ -53,26 +56,17 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
// Ensure it's in a clean state val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
field.setBoolean(null, false)
assertEquals("initial value should be false", false, field.getBoolean(null)) // Ensure it's in a clean state
atomicBoolean.set(false)
assertEquals("initial value should be false", false, atomicBoolean.get())
} }
@Test @Test
fun `isAppInForeground field is volatile for thread safety`() { fun `isAppInForeground field is volatile for thread safety`() {
val field = // Obsolete test as the type is now AtomicBoolean which guarantees thread safety implicitly
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
val modifiers = field.modifiers
assertEquals(
"isAppInForeground must be @Volatile — it is read from a background " +
"coroutine (event collection) and written from the UI thread " +
"(setAppForeground called from ChatScreen lifecycle)",
true,
Modifier.isVolatile(modifiers),
)
} }
@Test @Test
@@ -82,10 +76,12 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Set true twice // Set true twice
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
assertEquals("flag should remain true after consecutive setAppForeground(true)", true, field.getBoolean(null)) assertEquals("flag should remain true after consecutive setAppForeground(true)", true, atomicBoolean.get())
// Set false twice // Set false twice
ChatNotificationService.setAppForeground(false) ChatNotificationService.setAppForeground(false)
@@ -93,7 +89,7 @@ class ChatNotificationServiceTest {
assertEquals( assertEquals(
"flag should remain false after consecutive setAppForeground(false)", "flag should remain false after consecutive setAppForeground(false)",
false, false,
field.getBoolean(null), atomicBoolean.get(),
) )
} }
} }
@@ -252,6 +252,27 @@ class NotificationReplyReceiverTest {
verify { mockPendingResult.finish() } verify { mockPendingResult.finish() }
} }
@Test
fun `Room hang triggers timeout and finishes pending result`() {
givenValidReply("session-abc", "Hello")
// Make upsert hang indefinitely
coEvery { mockDao.upsert(any<ChatMessageEntity>()) } coAnswers {
kotlinx.coroutines.delay(10000)
}
receiver.onReceive(mockContext, mockIntent)
// Wait longer than the 5-second timeout, but less than the 10-second delay
Thread.sleep(6000)
// Timeout should be caught in catch block and logged
verify { android.util.Log.e("NotificationReply", any<String>(), any()) }
// Pending result must still finish after timeout
verify { mockPendingResult.finish() }
}
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────
private fun givenValidReply( private fun givenValidReply(
@@ -64,6 +64,8 @@ class ChatViewModelTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token" every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } returns Unit every { HermesWsClient.connect() } returns Unit
@@ -220,6 +220,35 @@ class ConnectViewModelTest {
viewModel.connect() viewModel.connect()
advanceUntilIdle() advanceUntilIdle()
verify { AuthManager.setToken(null) }
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Invalid token (401 Unauthorized)", state.errorMessage)
}
@Test
fun testConnect_failure_unauthorized_clearsProfileToken() =
runTest {
every { AuthManager.getSelectedProfileId() } returns "prof-1"
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 401
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
verify { AuthManager.setToken(null) }
verify { AuthManager.setProfileToken("prof-1", null) }
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertFalse(state.isConnecting) assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess) assertFalse(state.connectionSuccess)
@@ -584,12 +613,14 @@ class ConnectViewModelTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123") viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
// Wait for parsing coroutine to finish
advanceUntilIdle()
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("192.168.1.1", state.host) assertEquals("192.168.1.1", state.host)
assertEquals("8888", state.port) assertEquals("8888", state.port)
assertEquals("abc123", state.token) assertEquals("abc123", state.token)
// Should have triggered connect // Should have triggered connect
advanceUntilIdle()
assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess) assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess)
} }
} }