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
@@ -0,0 +1,47 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ChatMessageDao {
|
||||
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
|
||||
fun observeMessagesForSession(sessionId: String): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
|
||||
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: ChatMessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
||||
|
||||
@Query("UPDATE chat_messages SET content = :content, is_streaming = 0 WHERE id = :id")
|
||||
suspend fun finalizeMessage(
|
||||
id: String,
|
||||
content: String,
|
||||
)
|
||||
|
||||
@Query("UPDATE chat_messages SET content = :content WHERE id = :id")
|
||||
suspend fun updateContent(
|
||||
id: String,
|
||||
content: String,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
|
||||
suspend fun deleteMessagesForSession(sessionId: String)
|
||||
|
||||
@Query(
|
||||
"DELETE FROM chat_messages WHERE session_id NOT IN " +
|
||||
"(SELECT DISTINCT session_id FROM chat_messages " +
|
||||
"ORDER BY timestamp DESC LIMIT :keepSessions)",
|
||||
)
|
||||
suspend fun pruneOldSessions(keepSessions: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM chat_messages")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage]
|
||||
* UI model but is stored independently so it survives process death.
|
||||
*
|
||||
* Messages are scoped by [sessionId] so switching sessions loads the right
|
||||
* thread. The [createdAt] field preserves original ordering even if Room
|
||||
* reorders internally.
|
||||
*/
|
||||
@Entity(tableName = "chat_messages")
|
||||
data class ChatMessageEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "session_id")
|
||||
val sessionId: String,
|
||||
@ColumnInfo(name = "role")
|
||||
val role: String,
|
||||
@ColumnInfo(name = "content")
|
||||
val content: String,
|
||||
@ColumnInfo(name = "timestamp")
|
||||
val timestamp: Long,
|
||||
@ColumnInfo(name = "tool_name")
|
||||
val toolName: String? = null,
|
||||
@ColumnInfo(name = "tool_status")
|
||||
val toolStatus: String? = null,
|
||||
@ColumnInfo(name = "is_streaming")
|
||||
val isStreaming: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
|
||||
/**
|
||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||
*
|
||||
* Keep this as a pure mapping — no I/O, no side effects.
|
||||
*/
|
||||
|
||||
fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
ChatMessage(
|
||||
id = id,
|
||||
role =
|
||||
when (role) {
|
||||
"USER" -> MessageRole.USER
|
||||
"ASSISTANT" -> MessageRole.ASSISTANT
|
||||
"SYSTEM" -> MessageRole.SYSTEM
|
||||
"TOOL" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
},
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
isStreaming = isStreaming,
|
||||
toolName = toolName,
|
||||
toolStatus =
|
||||
when (toolStatus) {
|
||||
"RUNNING" -> ToolStatus.RUNNING
|
||||
"COMPLETED" -> ToolStatus.COMPLETED
|
||||
"FAILED" -> ToolStatus.FAILED
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
ChatMessageEntity(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
role = role.name,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
toolName = toolName,
|
||||
toolStatus = toolStatus?.name,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
abstract fun chatMessageDao(): ChatMessageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: HermesDatabase? = null
|
||||
|
||||
fun get(context: Context): HermesDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
/** For testing — inject a custom instance. */
|
||||
fun setForTest(db: HermesDatabase?) {
|
||||
instance = db
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user