feat(chat): message persistence + background notifications (#2)
* 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>
This commit is contained in:
1 parent
7daf73ce0d
commit
5130c2e308
13 files changed
+722
-47
No files matched your search
@@ -1,11 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
@@ -13,6 +18,7 @@ import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
@@ -31,6 +37,9 @@ import org.junit.Test
|
||||
class ChatViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
private lateinit var app: Application
|
||||
private val mockDao: ChatMessageDao = mockk(relaxed = true)
|
||||
private val mockDb: HermesDatabase = mockk(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -43,11 +52,23 @@ class ChatViewModelTest {
|
||||
|
||||
mockkObject(AuthManager)
|
||||
mockkObject(HermesWsClient)
|
||||
mockkObject(HermesDatabase)
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
|
||||
every { AuthManager.getToken() } returns "test-token"
|
||||
every { HermesWsClient.events } returns mockEventsFlow
|
||||
every { HermesWsClient.connect() } returns Unit
|
||||
every { HermesWsClient.disconnect() } returns Unit
|
||||
every { mockDb.chatMessageDao() } returns mockDao
|
||||
every { HermesDatabase.get(any()) } returns mockDb
|
||||
every { mockDao.observeMessagesForSession(any()) } returns flowOf(emptyList())
|
||||
coEvery { mockDao.getMessagesForSession(any()) } returns emptyList()
|
||||
coEvery { mockDao.upsert(any()) } returns Unit
|
||||
coEvery { mockDao.upsertAll(any()) } returns Unit
|
||||
coEvery { mockDao.finalizeMessage(any(), any()) } returns Unit
|
||||
coEvery { mockDao.deleteMessagesForSession(any()) } returns Unit
|
||||
coEvery { mockDao.count() } returns 0
|
||||
|
||||
// Default mock stubs for requests returning unique IDs
|
||||
var reqCount = 0
|
||||
@@ -70,7 +91,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun testInitialStateAndConnection() =
|
||||
runTest {
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.connect() }
|
||||
@@ -81,7 +102,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun testGatewayReady_createsSessionIfNoneExists() =
|
||||
runTest {
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
@@ -107,7 +128,7 @@ class ChatViewModelTest {
|
||||
createReqId
|
||||
}
|
||||
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Trigger GatewayReady -> triggers createNewSession
|
||||
@@ -135,7 +156,7 @@ class ChatViewModelTest {
|
||||
listReqId
|
||||
}
|
||||
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
@@ -169,7 +190,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun testMessageStreamingFlow() =
|
||||
runTest {
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Set active session ID first
|
||||
@@ -242,7 +263,7 @@ class ChatViewModelTest {
|
||||
createReqId
|
||||
}
|
||||
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Set session ID
|
||||
@@ -276,7 +297,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun testSendMessage() =
|
||||
runTest {
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Trigger GatewayReady -> triggers createSession -> feed result to set active session
|
||||
@@ -309,7 +330,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun testSwitchSession() =
|
||||
runTest {
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.switchSession("session-456")
|
||||
@@ -339,7 +360,7 @@ class ChatViewModelTest {
|
||||
createReqId
|
||||
}
|
||||
|
||||
val viewModel = ChatViewModel()
|
||||
val viewModel = ChatViewModel(app)
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
|
||||
Reference in new issue
Block a user