MainActivity is exported (launcher requirement) and consumed the
session-id extra from any incoming intent without checking the action —
a foreign app could launch it explicitly and force-navigate to a chat
screen. Guard consumeNotificationIntent() on the app's own notification
action and hoist the action string into a shared const so
ChatNotificationService and MainActivity can never drift.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
The gateway replays the full transcript over a new socket on
session.resume unless omit_messages is set; mobile hydrates history via
REST in parallel, so the replay is redundant. When a reconnect lands
mid-stream, the replayed message gets a fresh UUID and dedup can't
collapse the 90% orphan with the 100% replay (different ids + content),
leaving a duplicate bubble. Desktop already sends omit_messages: true;
mirror that in both resume paths (ChatViewModel + NotificationReplyReceiver)
and pin it in the exact-map test verifies.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Added `setPackage(packageName)` to the explicitly defined reply intent
before wrapping it in the mutable PendingIntent for remote input.
RemoteInput intents strictly require `FLAG_MUTABLE` so the Android OS
can append the user's text reply. To use `FLAG_MUTABLE` securely
and prevent unintentional component delivery, explicit component
boundaries (like `setPackage`) must be defined.
The chat notification FGS — and its mandatory persistent 'Waiting for
Hermes replies' notification — was started on every ON_STOP of the chat
screen, even when nothing was in flight, and lingered at app startup.
Track a pending-reply flag on HermesWsClient (set when a prompt is
submitted and on any in-flight turn event, cleared on MessageComplete),
gate NotificationHelper.start() on it, and have the service retire
itself after delivering the reply notification in the background.
Replies sent from the notification re-arm the service for the
follow-up turn.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Explicit IO context for DB/WS/notify path. replyScope already uses
Dispatchers.IO; this makes the offload local and explicit. Keep return@withContext
only because nothing follows the withContext block today.
* Security: Validate session ID in NotificationReplyReceiver
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
Fixes#268
* Security: Validate session ID in NotificationReplyReceiver
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine and wrapped with a 5s timeout to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
Fixes#268
* Security: Validate session ID in NotificationReplyReceiver
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine and wrapped with a 5s timeout to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
Fixes#268
* Security: Fix fake token triggering GitGuardian
- Replaced high-entropy string `ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890` with obvious fake string `dummy_token_value_that_is_long_enough_to_pass_validation_123` in `ConnectViewModelTest.kt` to fix a false positive alert during GitHub Actions GitGuardian security check.
Fixes#268 CI Check failure
* Security: Fix fake token triggering GitGuardian and validate session ID
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine and wrapped with a 5s timeout to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
- Replaced high-entropy string `ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890` with obvious fake string `dummy_token_value_that_is_long_enough_to_pass_validation_123` in `ConnectViewModelTest.kt` to fix a false positive alert during GitHub Actions GitGuardian security check.
Fixes#268
* Security: Fix fake token triggering GitGuardian and validate session ID
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine and wrapped with a 5s timeout to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
- Replaced high-entropy string `ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890` with obvious fake string `dummy_token_value_that_is_long_enough_to_pass_validation_123` in `ConnectViewModelTest.kt` to fix a false positive alert during GitHub Actions GitGuardian security check.
Fixes#268
* Security: Validate session ID in NotificationReplyReceiver and fix false positive secret
- Updated `ChatMessageDao.kt` to include `sessionExists` query.
- Guarded `NotificationReplyReceiver.kt` `HermesWsClient.sendMessage` execution behind the `dao.sessionExists(sessionId)` check, preventing potential intent injection payloads against inactive/unknown sessions.
- Moved message dispatch into DB IO coroutine and wrapped with a 5s timeout to ensure safe execution.
- Added tests in `NotificationReplyReceiverTest.kt` to assert unknown sessions ignore replies properly.
- Replaced high-entropy string `ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890` with obvious fake string `dummy_token_value_that_is_long_enough_to_pass_validation_123` in `ConnectViewModelTest.kt` to fix a false positive alert during GitHub Actions GitGuardian security check.
Fixes#268
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* Fix: Prevent AuthManager state pollution on connection verification
Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.
Fixes#260
* Fix: Prevent AuthManager state pollution on connection verification
Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.
Fixes#260
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- Replace volatile boolean with AtomicBoolean for thread-safe state access
- Add a 500ms debounce to notification logic to account for delayed foreground transitions
- Update ChatNotificationService unit tests to reflect the new AtomicBoolean state type
This fixes a race condition where a message arriving just as the user opens the app would incorrectly trigger a foreground notification.
This commit addresses issue #265 where the NotificationReplyReceiver
used an unbound CoroutineScope when handling asynchronous reply saves
to the Room database. If the DAO operation hangs, the PendingResult.finish()
method is never called, which could lead to an Application Not Responding (ANR)
error.
Changes made:
- Wrapped the Room upsert operation inside a `withTimeout(5000L)` block
within the IO coroutine in `NotificationReplyReceiver.kt`.
- Ensures that if the operation exceeds 5 seconds, a TimeoutCancellationException
is thrown and caught, allowing `pendingResult.finish()` to execute in the
`finally` block.
- Added a unit test in `NotificationReplyReceiverTest.kt` to verify that
if the mock DAO hangs indefinitely, the PendingResult is still correctly finished
after the timeout.
Impact:
- Prevents potential ANRs caused by stuck local DB transactions during inline
notification replies.
Set `NotificationCompat.VISIBILITY_PRIVATE` on the chat reply
notification builder. This prevents the first 100 characters of a
message from leaking to the lock screen, addressing security concern
SEC-09.
Resolves#262
* test(#291): add coverage for 3 critical untested components
- NavigationController dedup guard (line 45): 14 pure unit tests for
navigateTo, resetTo, goBack, primary-screen clearing, and null guards.
- NotificationReplyReceiver: 11 MockK-based tests covering the full
WS+Room+notification flow: valid reply sends WS message, persists to
Room with correct entity shape, shows replied confirmation notification,
finishes PendingResult, and gracefully handles null/blank inputs and
Room failures.
- ChatNotificationService isAppInForeground flag: 5 tests verifying
companion object toggle, volatile modifier (thread safety), default
false state, and idempotent calls.
- Fix: add @Volatile to isAppInForeground (was a plain var read from
background coroutine and written from UI thread)
Closes#291
* fix(#291): fix MockK type errors in test compilation
* fix(#291): fix test assertions and goAsync mock for CI
* fix(#291): wrap final BroadcastReceiver.goAsync() with open Kotlin wrapper
* chore: trigger CI on latest fix commit
* fix(#291): make NotificationReplyReceiver open so tests can subclass it
* fix(#291): extract buildReplyNotification() to avoid 'not mocked' framework error
NotificationCompat.Builder.build() internally calls android.app.Notification.Builder
methods (e.g. setWhen) that throw RuntimeException with 'Method setWhen in
android.app.Notification.Builder not mocked' in unit tests — the standard Android
unit test environment does not provide real framework implementations.
Extracting notification creation into an open method (same pattern as goAsyncCompat())
lets tests override it to return a mock Notification, avoiding the framework call
entirely.
* style: fix ktlint blank-line-before-declaration in test
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#240): parse session_id from params root
Allows correct session mapping when opening app via notification.
* fix(#240): set MainActivity launchMode to singleTop
Prevents activity destruction and WebSocket reconnection when tapping notifications.
* fix(#240): save inline notification replies to Room database
* fix(#240): refresh current session when app comes to foreground
* fix(#240): use uppercase role string to match Mapper mapping expectations
* style: format NotificationReplyReceiver.kt to satisfy ktlint
Root cause: MainActivity had no onNewIntent override. When the app was
backgrounded and a notification was tapped, Android brought the existing
task to the foreground and delivered the intent via onNewIntent — but
since it wasn't overridden, the session ID from the notification was
silently dropped and the user saw the last-active session.
Fix:
1. Changed notificationSessionId from a local val to a Compose
mutableStateOf so it's observable across recompositions.
2. Added onNewIntent override that reads the session ID from the
incoming intent and updates the observable state.
3. Added FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP to the
notification PendingIntent for reliable task management.
Notification smallIcon must use a simple mono vector drawable, not a
launcher mipmap. Launcher icons render broken in the status bar on
many devices.
- Added app/src/main/res/drawable/ic_notification.xml (simple info icon)
- Updated both showReplyNotification and buildForegroundNotification to
reference R.drawable.ic_notification instead of R.mipmap.ic_launcher
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(chat): message persistence + background notifications
Two critical gaps for daily-driver use:
1. Message persistence (Room database):
- New ChatMessageEntity / ChatMessageDao / HermesDatabase layer
- Every user message, streaming token, tool result, and assistant
reply is written to a local Room DB as it arrives
- switchSession loads cached messages instantly from Room before
the REST call returns — no more blank screen while loading
- Session resume also loads from cache first
- Messages survive app kills, process death, and configuration
changes
- ChatViewModel changed from ViewModel() to AndroidViewModel(app)
to access the database context
2. Background notifications (foreground service):
- ChatNotificationService keeps the WebSocket alive while the app
is backgrounded and posts a high-priority notification when a
MessageComplete or ClarifyRequest event arrives while the app
is not in the foreground
- Lifecycle wired via LifecycleEventObserver in ChatScreen:
ON_STOP starts the service, ON_START stops it
- Tapping the notification returns to the chat
- New permissions: POST_NOTIFICATIONS, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_DATA_SYNC
Build changes:
- Added Room 2.7.1 + KSP to version catalog
- Added WorkManager 2.10.0 (reserved for future polling fallback)
- Updated ChatViewModelTest to mock HermesDatabase + use
AndroidViewModel constructor
* fix(build): use KSP 2.3.9 (new versioning scheme for Kotlin 2.3.x)
* fix(build): add Room schemaDirectory (required by Room Gradle plugin)
* fix(compile): add else branch to WsEvent when expression (exhaustiveness)
* ci(release): add release workflow + R8 minification + proguard rules
New .github/workflows/release.yml:
- Triggers on tag pushes (v*) and manual workflow_dispatch
- Runs full CI (ktlint + lint + test) before building
- Builds assembleRelease with version injection from tag
- Creates a GitHub Release with auto-generated changelog
- Attaches the release APK to the release
Build changes:
- Release builds now use R8 minification (isMinifyEnabled=true)
and resource shrinking (isShrinkResources=true) for smaller APKs
- versionCode/versionName injectable via -PversionCode / -PversionName
Gradle properties (passed by CI from the git tag)
- Local dev builds default to versionCode=1, versionName=1.0-dev
ProGuard rules:
- Keep all data model classes (Gson reflection)
- Keep Room entities and RoomDatabase subclasses
- Keep OkHttp/Retrofit interface methods
- Suppress warnings for Compose, Security Crypto, Coroutines
* feat(ci): release workflow + proguard rules + test fix
Release workflow (.github/workflows/release.yml):
- Triggers on tag push (v*) or manual workflow_dispatch
- Two jobs: ci (ktlint+lint+test) → build-release
- Builds a minified, shrunk release APK with version from tag
- Creates a GitHub Release with auto-generated release notes
- Attaches the APK as a release asset
Build config:
- versionCode/versionName now injected from CI via -P flags
- Release build enables isMinifyEnabled + isShrinkResources
- proguard-rules.pro: keep rules for Gson models, Room entities,
OkHttp/Retrofit interfaces, Coroutines, Compose, BuildConfig
Test fix:
- ChatViewModelTest: use coEvery for suspend DAO methods instead
of every (compile error — suspend functions need coEvery)
- Added stubs for all suspend DAO methods used by the ViewModel
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>