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:
M57andM57 authored and GitHub committed 2026-06-18 03:23:38 +03:00
1 parent 7daf73ce0d
commit 5130c2e308
13 files changed
+722 -47

No files matched your search

@@ -66,6 +66,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,11 +78,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
@@ -91,13 +94,41 @@ fun ChatScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ChatViewModel = viewModel { ChatViewModel() },
viewModel: ChatViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var inputText by rememberSaveable { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Manage notification foreground service lifecycle:
// - When app goes to background (ON_STOP), mark as not-foreground and
// start the notification service so we can post reply notifications.
// - When app returns to foreground (ON_START), mark as foreground and
// stop the notification service.
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.currentStreamingText) {
@@ -1,8 +1,12 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -42,12 +46,13 @@ data class ClarifyUi(
val options: List<String>,
)
class ChatViewModel : ViewModel() {
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
private val dao = HermesDatabase.get(application).chatMessageDao()
init {
connectWebSocket()
@@ -75,15 +80,12 @@ class ChatViewModel : ViewModel() {
_uiState.update { it.copy(isConnected = true, isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
// Create a new session if none exists
if (_uiState.value.currentSessionId == null) {
createNewSession()
}
}
is WsEvent.SessionInfo -> {
// We may receive session info after creation
}
is WsEvent.SessionInfo -> {}
is WsEvent.MessageStart -> {
_uiState.update {
@@ -99,7 +101,6 @@ class ChatViewModel : ViewModel() {
is WsEvent.MessageToken -> {
_uiState.update { state ->
val newText = state.currentStreamingText + event.token
// Update or create the streaming message
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
@@ -116,6 +117,14 @@ class ChatViewModel : ViewModel() {
),
)
}
// Persist streaming message to DB
val sessionId = state.currentSessionId
if (sessionId != null) {
val msg = messages.last()
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
state.copy(
messages = messages,
currentStreamingText = newText,
@@ -140,19 +149,28 @@ class ChatViewModel : ViewModel() {
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
val finalMessage: ChatMessage
if (streamingIdx >= 0) {
messages[streamingIdx] =
finalMessage =
messages[streamingIdx].copy(
content = event.text,
isStreaming = false,
)
messages[streamingIdx] = finalMessage
} else {
messages.add(
finalMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
),
)
)
messages.add(finalMessage)
}
// Persist finalized message
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.finalizeMessage(finalMessage.id, event.text)
}
}
state.copy(
messages = messages,
@@ -179,6 +197,12 @@ class ChatViewModel : ViewModel() {
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(toolMessage.toEntity(sessionId))
}
}
state.copy(messages = state.messages + toolMessage)
}
}
@@ -198,6 +222,13 @@ class ChatViewModel : ViewModel() {
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
// Persist tool completion
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(messages[toolIdx].toEntity(sessionId))
}
}
}
state.copy(messages = messages)
}
@@ -215,12 +246,9 @@ class ChatViewModel : ViewModel() {
}
}
is WsEvent.StatusUpdate -> {
// Can be used for general status
}
is WsEvent.StatusUpdate -> {}
is WsEvent.SessionUpdated -> {
// Refresh sessions list
loadSessions()
}
@@ -232,9 +260,7 @@ class ChatViewModel : ViewModel() {
handleRpcError(event.id, event.error)
}
is WsEvent.Unknown -> {
// Handle or ignore unknown/unhandled events
}
is WsEvent.Unknown -> {}
}
}
@@ -253,10 +279,10 @@ class ChatViewModel : ViewModel() {
it.copy(
currentSessionId = sessionId,
isLoading = false,
messages = it.messages,
messages = emptyList(),
)
}
addSystemMessage("Session created")
addSystemMessage("Session created", persist = true)
loadSessions()
}
@@ -276,6 +302,11 @@ class ChatViewModel : ViewModel() {
WsMethods.SESSION_RESUME -> {
_uiState.update { it.copy(isLoading = false) }
// Load cached messages from Room before the REST call
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
loadCachedMessages(sessionId)
}
addSystemMessage("Session resumed")
}
@@ -316,15 +347,18 @@ class ChatViewModel : ViewModel() {
return
}
// Add user message to UI
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = text,
)
_uiState.update { state ->
val messages = state.messages + userMessage
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId))
}
state.copy(
messages = state.messages + userMessage,
messages = messages,
isAgentTyping = true,
)
}
@@ -337,7 +371,13 @@ class ChatViewModel : ViewModel() {
private fun handleSlashCommand(command: String) {
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + userMsg) }
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMsg.toEntity(sessionId))
}
}
val parts = command.split(" ")
val cmd = parts[0].lowercase()
@@ -383,7 +423,7 @@ class ChatViewModel : ViewModel() {
• **Gateway Running:** ${body.gateway_running ?: false}
• **Active Sessions:** ${body.active_sessions ?: 0}
• **Auth Required:** ${body.auth_required ?: false}
**Platforms:**
$platformsStr
""".trimIndent()
@@ -451,7 +491,13 @@ class ChatViewModel : ViewModel() {
private fun addAssistantMessage(text: String) {
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + msg) }
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
}
fun interruptSession() {
@@ -491,6 +537,9 @@ class ChatViewModel : ViewModel() {
showSessionPicker = false,
)
}
// Load cached messages first — instant display
loadCachedMessages(sessionId)
// Then resume session on server
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
@@ -502,6 +551,21 @@ class ChatViewModel : ViewModel() {
loadSessionMessages(sessionId)
}
private fun loadCachedMessages(sessionId: String) {
viewModelScope.launch(Dispatchers.IO) {
val cached = dao.getMessagesForSession(sessionId)
val cachedMessages = cached.map { it.toUiModel() }
_uiState.update { state ->
// Only replace if still showing this session and messages are empty/newer
if (state.currentSessionId == sessionId) {
state.copy(messages = cachedMessages, isLoading = false)
} else {
state
}
}
}
}
private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch {
try {
@@ -526,15 +590,11 @@ class ChatViewModel : ViewModel() {
isStreaming = false,
)
}
// B3 (Jun 18 2026, kanban t_33da8a97): switchSession fires
// SESSION_RESUME (WS) and loadSessionMessages (REST) in
// parallel. While this REST call is in flight, WsEvent
// .MessageToken events append streaming messages to
// `state.messages` (ChatViewModel.kt:99-125). Without
// preservation, the unconditional `messages = chatMessages`
// below stomps the just-streamed tokens. Preserve the
// in-flight tail by appending any messages marked
// isStreaming=true to the freshly-loaded history.
// Persist loaded messages to Room for offline access
val entities = chatMessages.map { it.toEntity(sessionId) }
viewModelScope.launch(Dispatchers.IO) {
dao.upsertAll(entities)
}
_uiState.update { state ->
val streamingTail = state.messages.filter { it.isStreaming }
state.copy(
@@ -595,9 +655,20 @@ class ChatViewModel : ViewModel() {
}
}
private fun addSystemMessage(text: String) {
private fun addSystemMessage(
text: String,
persist: Boolean = false,
) {
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
_uiState.update { it.copy(messages = it.messages + msg) }
if (persist) {
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
}
}
override fun onCleared() {