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,124 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag (e.g. v1.0.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Run full CI before building release ───────────────────────────────
|
||||||
|
ci:
|
||||||
|
name: CI Checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Grant Execute Permission for Gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Install ktlint
|
||||||
|
run: |
|
||||||
|
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint
|
||||||
|
chmod +x ktlint
|
||||||
|
sudo mv ktlint /usr/local/bin/
|
||||||
|
|
||||||
|
- name: ktlintCheck
|
||||||
|
run: ./gradlew ktlintCheck
|
||||||
|
|
||||||
|
- name: lintDebug
|
||||||
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: ./gradlew test
|
||||||
|
|
||||||
|
# ── Build release APK ─────────────────────────────────────────────────
|
||||||
|
build-release:
|
||||||
|
name: Build Release APK
|
||||||
|
needs: ci
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Grant Execute Permission for Gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
TAG="${{ github.event.inputs.tag }}"
|
||||||
|
else
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
# Strip leading 'v' for versionName
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
# versionCode: strip dots from version, pad to keep sortable
|
||||||
|
CODE=$(echo "$VERSION" | tr -d '.' | sed 's/^0*//')
|
||||||
|
[ -z "$CODE" ] && CODE=0
|
||||||
|
echo "code=$CODE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release: tag=$TAG version=$VERSION code=$CODE"
|
||||||
|
|
||||||
|
- name: Build Release APK
|
||||||
|
env:
|
||||||
|
VERSION_NAME: ${{ steps.version.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||||
|
run: |
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-PversionName="$VERSION_NAME" \
|
||||||
|
-PversionCode="$VERSION_CODE"
|
||||||
|
|
||||||
|
- name: Rename APK
|
||||||
|
run: |
|
||||||
|
mkdir -p release
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk \
|
||||||
|
release/hermes-control-${{ steps.version.outputs.tag }}.apk
|
||||||
|
ls -lh release/
|
||||||
|
|
||||||
|
- name: Upload Release APK Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hermes-control-release
|
||||||
|
path: release/*.apk
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
|
name: Hermes Control ${{ steps.version.outputs.tag }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: release/*.apk
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+20
-3
@@ -4,6 +4,8 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.room)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -13,13 +15,16 @@ android {
|
|||||||
applicationId = "com.m57.hermescontrol"
|
applicationId = "com.m57.hermescontrol"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||||
versionName = "1.0"
|
// Falls back to defaults for local development.
|
||||||
|
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||||
|
versionName = (project.findProperty("versionName") as? String) ?: "1.0-dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
@@ -46,6 +51,10 @@ kotlin {
|
|||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
room {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val composeBom = platform(libs.androidx.compose.bom)
|
val composeBom = platform(libs.androidx.compose.bom)
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
@@ -82,6 +91,14 @@ dependencies {
|
|||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation("androidx.startup:startup-runtime:1.1.1")
|
implementation("androidx.startup:startup-runtime:1.1.1")
|
||||||
|
|
||||||
|
// Local database (Room) — message persistence
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
// Background notifications (WorkManager)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
|
|
||||||
// Local tests: jUnit, coroutines, Android runner
|
// Local tests: jUnit, coroutines, Android runner
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.junit.jupiter.api)
|
testImplementation(libs.junit.jupiter.api)
|
||||||
|
|||||||
Vendored
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# ── Kotlin / Coroutines ─────────────────────────────────────────────────
|
||||||
|
-keepclassmembers class kotlinx.coroutines.** { *; }
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# ── Room ─────────────────────────────────────────────────────────────────
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-keep @androidx.room.Entity class * { *; }
|
||||||
|
-keepclassmembers class * { @androidx.room.* <methods>; }
|
||||||
|
-dontwarn androidx.room.**
|
||||||
|
|
||||||
|
# ── Gson / Serialization ─────────────────────────────────────────────────
|
||||||
|
# Keep all data model classes — Gson uses reflection to access fields
|
||||||
|
-keep class com.m57.hermescontrol.data.model.** { *; }
|
||||||
|
-keep class com.m57.hermescontrol.data.ws.JsonRpc* { *; }
|
||||||
|
-keep class com.m57.hermescontrol.data.ws.WsEvent* { *; }
|
||||||
|
-keep class com.m57.hermescontrol.data.ws.WsMethods { *; }
|
||||||
|
-keep class com.m57.hermescontrol.ui.chat.ChatMessage { *; }
|
||||||
|
-keep class com.m57.hermescontrol.ui.chat.MessageRole { *; }
|
||||||
|
-keep class com.m57.hermescontrol.ui.chat.ToolStatus { *; }
|
||||||
|
|
||||||
|
# Gson itself
|
||||||
|
-dontwarn com.google.gson.**
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
|
||||||
|
# Generic type signatures used by Gson
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
|
||||||
|
# ── OkHttp / Retrofit ────────────────────────────────────────────────────
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
-dontwarn retrofit2.**
|
||||||
|
-keepattributes RuntimeVisibleAnnotations
|
||||||
|
-keepattributes RuntimeInvisibleAnnotations
|
||||||
|
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||||
|
@retrofit2.http.* <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── AndroidX Security Crypto ─────────────────────────────────────────────
|
||||||
|
-dontwarn androidx.security.crypto.**
|
||||||
|
|
||||||
|
# ── Compose ───────────────────────────────────────────────────────────────
|
||||||
|
-dontwarn androidx.compose.**
|
||||||
|
|
||||||
|
# ── BuildConfig ───────────────────────────────────────────────────────────
|
||||||
|
-keep class com.m57.hermescontrol.BuildConfig { *; }
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -21,6 +24,11 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".notification.ChatNotificationService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.m57.hermescontrol.notification
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.m57.hermescontrol.MainActivity
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that keeps the WebSocket connection alive while the app
|
||||||
|
* is backgrounded and posts a notification when a new assistant reply
|
||||||
|
* completes (MessageComplete event) while the app is not in the foreground.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - Started by [NotificationHelper.start] when the user sends a message and
|
||||||
|
* the app is about to go to the background (called from ChatScreen's
|
||||||
|
* onStop / onPause).
|
||||||
|
* - Stopped by [NotificationHelper.stop] when the app returns to the
|
||||||
|
* foreground (called from ChatScreen's onStart / onResume).
|
||||||
|
*
|
||||||
|
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
|
||||||
|
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
|
||||||
|
* events that indicate the agent has finished replying.
|
||||||
|
*/
|
||||||
|
class ChatNotificationService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "hermes_chat"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val PENDING_NOTIFICATION_ID = 2
|
||||||
|
|
||||||
|
private var isAppInForeground = false
|
||||||
|
|
||||||
|
fun setAppForeground(foreground: Boolean) {
|
||||||
|
isAppInForeground = foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private var eventCollector: Job? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, buildForegroundNotification("Hermes connected"))
|
||||||
|
startEventCollection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startEventCollection() {
|
||||||
|
eventCollector =
|
||||||
|
serviceScope.launch {
|
||||||
|
HermesWsClient.events.collect { event ->
|
||||||
|
if (!isAppInForeground) {
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.MessageComplete -> {
|
||||||
|
val preview =
|
||||||
|
event.text
|
||||||
|
.take(100)
|
||||||
|
.replace("\n", " ")
|
||||||
|
.ifBlank { "New message" }
|
||||||
|
showReplyNotification(preview)
|
||||||
|
}
|
||||||
|
is WsEvent.ClarifyRequest -> {
|
||||||
|
showReplyNotification("Hermes needs a clarification")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showReplyNotification(text: String) {
|
||||||
|
val notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle("Hermes")
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(buildContentIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.notify(PENDING_NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContentIntent(): PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildForegroundNotification(text: String): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle("Hermes")
|
||||||
|
.setContentText(text)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Hermes Chat",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = "Notifications for new Hermes replies"
|
||||||
|
}
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(
|
||||||
|
intent: Intent?,
|
||||||
|
flags: Int,
|
||||||
|
startId: Int,
|
||||||
|
): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
eventCollector?.cancel()
|
||||||
|
serviceScope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to start/stop the notification service from the UI layer.
|
||||||
|
*/
|
||||||
|
object NotificationHelper {
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (AuthManager.getToken().isNullOrBlank()) return
|
||||||
|
val intent = Intent(context, ChatNotificationService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, ChatNotificationService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppForeground(
|
||||||
|
context: Context,
|
||||||
|
foreground: Boolean,
|
||||||
|
) {
|
||||||
|
ChatNotificationService.setAppForeground(foreground)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -77,11 +78,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.StatusGreen
|
import com.m57.hermescontrol.theme.StatusGreen
|
||||||
import com.m57.hermescontrol.theme.StatusRed
|
import com.m57.hermescontrol.theme.StatusRed
|
||||||
|
|
||||||
@@ -91,13 +94,41 @@ fun ChatScreen(
|
|||||||
onNavigateToSettings: () -> Unit,
|
onNavigateToSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onOpenDrawer: (() -> Unit)? = null,
|
onOpenDrawer: (() -> Unit)? = null,
|
||||||
viewModel: ChatViewModel = viewModel { ChatViewModel() },
|
viewModel: ChatViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
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
|
// Auto-scroll to bottom on new messages
|
||||||
LaunchedEffect(state.messages.size, state.currentStreamingText) {
|
LaunchedEffect(state.messages.size, state.currentStreamingText) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
@@ -42,12 +46,13 @@ data class ClarifyUi(
|
|||||||
val options: List<String>,
|
val options: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ChatViewModel : ViewModel() {
|
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val wsClient = HermesWsClient
|
private val wsClient = HermesWsClient
|
||||||
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
|
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
|
||||||
|
private val dao = HermesDatabase.get(application).chatMessageDao()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
connectWebSocket()
|
connectWebSocket()
|
||||||
@@ -75,15 +80,12 @@ class ChatViewModel : ViewModel() {
|
|||||||
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
||||||
addSystemMessage("Connected to Hermes")
|
addSystemMessage("Connected to Hermes")
|
||||||
loadSessions()
|
loadSessions()
|
||||||
// Create a new session if none exists
|
|
||||||
if (_uiState.value.currentSessionId == null) {
|
if (_uiState.value.currentSessionId == null) {
|
||||||
createNewSession()
|
createNewSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.SessionInfo -> {
|
is WsEvent.SessionInfo -> {}
|
||||||
// We may receive session info after creation
|
|
||||||
}
|
|
||||||
|
|
||||||
is WsEvent.MessageStart -> {
|
is WsEvent.MessageStart -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -99,7 +101,6 @@ class ChatViewModel : ViewModel() {
|
|||||||
is WsEvent.MessageToken -> {
|
is WsEvent.MessageToken -> {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val newText = state.currentStreamingText + event.token
|
val newText = state.currentStreamingText + event.token
|
||||||
// Update or create the streaming message
|
|
||||||
val messages = state.messages.toMutableList()
|
val messages = state.messages.toMutableList()
|
||||||
val streamingIdx =
|
val streamingIdx =
|
||||||
messages.indexOfLast {
|
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(
|
state.copy(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
currentStreamingText = newText,
|
currentStreamingText = newText,
|
||||||
@@ -140,19 +149,28 @@ class ChatViewModel : ViewModel() {
|
|||||||
messages.indexOfLast {
|
messages.indexOfLast {
|
||||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||||
}
|
}
|
||||||
|
val finalMessage: ChatMessage
|
||||||
if (streamingIdx >= 0) {
|
if (streamingIdx >= 0) {
|
||||||
messages[streamingIdx] =
|
finalMessage =
|
||||||
messages[streamingIdx].copy(
|
messages[streamingIdx].copy(
|
||||||
content = event.text,
|
content = event.text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
|
messages[streamingIdx] = finalMessage
|
||||||
} else {
|
} else {
|
||||||
messages.add(
|
finalMessage =
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = event.text,
|
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(
|
state.copy(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
@@ -179,6 +197,12 @@ class ChatViewModel : ViewModel() {
|
|||||||
toolStatus = ToolStatus.RUNNING,
|
toolStatus = ToolStatus.RUNNING,
|
||||||
)
|
)
|
||||||
_uiState.update { state ->
|
_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)
|
state.copy(messages = state.messages + toolMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +222,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
toolStatus = ToolStatus.COMPLETED,
|
toolStatus = ToolStatus.COMPLETED,
|
||||||
content = event.data.toString(),
|
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)
|
state.copy(messages = messages)
|
||||||
}
|
}
|
||||||
@@ -215,12 +246,9 @@ class ChatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.StatusUpdate -> {
|
is WsEvent.StatusUpdate -> {}
|
||||||
// Can be used for general status
|
|
||||||
}
|
|
||||||
|
|
||||||
is WsEvent.SessionUpdated -> {
|
is WsEvent.SessionUpdated -> {
|
||||||
// Refresh sessions list
|
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +260,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
handleRpcError(event.id, event.error)
|
handleRpcError(event.id, event.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.Unknown -> {
|
is WsEvent.Unknown -> {}
|
||||||
// Handle or ignore unknown/unhandled events
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,10 +279,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
it.copy(
|
it.copy(
|
||||||
currentSessionId = sessionId,
|
currentSessionId = sessionId,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
messages = it.messages,
|
messages = emptyList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addSystemMessage("Session created")
|
addSystemMessage("Session created", persist = true)
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +302,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
|
|
||||||
WsMethods.SESSION_RESUME -> {
|
WsMethods.SESSION_RESUME -> {
|
||||||
_uiState.update { it.copy(isLoading = false) }
|
_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")
|
addSystemMessage("Session resumed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,15 +347,18 @@ class ChatViewModel : ViewModel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user message to UI
|
|
||||||
val userMessage =
|
val userMessage =
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
content = text,
|
content = text,
|
||||||
)
|
)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
|
val messages = state.messages + userMessage
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
dao.upsert(userMessage.toEntity(sessionId))
|
||||||
|
}
|
||||||
state.copy(
|
state.copy(
|
||||||
messages = state.messages + userMessage,
|
messages = messages,
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -337,7 +371,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
|
|
||||||
private fun handleSlashCommand(command: String) {
|
private fun handleSlashCommand(command: String) {
|
||||||
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
|
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
|
||||||
|
val sessionId = _uiState.value.currentSessionId
|
||||||
_uiState.update { it.copy(messages = it.messages + userMsg) }
|
_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 parts = command.split(" ")
|
||||||
val cmd = parts[0].lowercase()
|
val cmd = parts[0].lowercase()
|
||||||
@@ -383,7 +423,7 @@ class ChatViewModel : ViewModel() {
|
|||||||
• **Gateway Running:** ${body.gateway_running ?: false}
|
• **Gateway Running:** ${body.gateway_running ?: false}
|
||||||
• **Active Sessions:** ${body.active_sessions ?: 0}
|
• **Active Sessions:** ${body.active_sessions ?: 0}
|
||||||
• **Auth Required:** ${body.auth_required ?: false}
|
• **Auth Required:** ${body.auth_required ?: false}
|
||||||
|
|
||||||
**Platforms:**
|
**Platforms:**
|
||||||
$platformsStr
|
$platformsStr
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
@@ -451,7 +491,13 @@ class ChatViewModel : ViewModel() {
|
|||||||
|
|
||||||
private fun addAssistantMessage(text: String) {
|
private fun addAssistantMessage(text: String) {
|
||||||
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
|
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
|
||||||
|
val sessionId = _uiState.value.currentSessionId
|
||||||
_uiState.update { it.copy(messages = it.messages + msg) }
|
_uiState.update { it.copy(messages = it.messages + msg) }
|
||||||
|
if (sessionId != null) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
dao.upsert(msg.toEntity(sessionId))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun interruptSession() {
|
fun interruptSession() {
|
||||||
@@ -491,6 +537,9 @@ class ChatViewModel : ViewModel() {
|
|||||||
showSessionPicker = false,
|
showSessionPicker = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Load cached messages first — instant display
|
||||||
|
loadCachedMessages(sessionId)
|
||||||
|
// Then resume session on server
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val requestId =
|
val requestId =
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
@@ -502,6 +551,21 @@ class ChatViewModel : ViewModel() {
|
|||||||
loadSessionMessages(sessionId)
|
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) {
|
private fun loadSessionMessages(sessionId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -526,15 +590,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// B3 (Jun 18 2026, kanban t_33da8a97): switchSession fires
|
// Persist loaded messages to Room for offline access
|
||||||
// SESSION_RESUME (WS) and loadSessionMessages (REST) in
|
val entities = chatMessages.map { it.toEntity(sessionId) }
|
||||||
// parallel. While this REST call is in flight, WsEvent
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
// .MessageToken events append streaming messages to
|
dao.upsertAll(entities)
|
||||||
// `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.
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val streamingTail = state.messages.filter { it.isStreaming }
|
val streamingTail = state.messages.filter { it.isStreaming }
|
||||||
state.copy(
|
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)
|
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||||
_uiState.update { it.copy(messages = it.messages + msg) }
|
_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() {
|
override fun onCleared() {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
@@ -13,6 +18,7 @@ import io.mockk.verify
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
@@ -31,6 +37,9 @@ import org.junit.Test
|
|||||||
class ChatViewModelTest {
|
class ChatViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
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
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -43,11 +52,23 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
mockkObject(AuthManager)
|
mockkObject(AuthManager)
|
||||||
mockkObject(HermesWsClient)
|
mockkObject(HermesWsClient)
|
||||||
|
mockkObject(HermesDatabase)
|
||||||
|
|
||||||
|
app = mockk(relaxed = true)
|
||||||
|
|
||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { HermesWsClient.events } returns mockEventsFlow
|
every { HermesWsClient.events } returns mockEventsFlow
|
||||||
every { HermesWsClient.connect() } returns Unit
|
every { HermesWsClient.connect() } returns Unit
|
||||||
every { HermesWsClient.disconnect() } 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
|
// Default mock stubs for requests returning unique IDs
|
||||||
var reqCount = 0
|
var reqCount = 0
|
||||||
@@ -70,7 +91,7 @@ class ChatViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testInitialStateAndConnection() =
|
fun testInitialStateAndConnection() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { HermesWsClient.connect() }
|
verify { HermesWsClient.connect() }
|
||||||
@@ -81,7 +102,7 @@ class ChatViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testGatewayReady_createsSessionIfNoneExists() =
|
fun testGatewayReady_createsSessionIfNoneExists() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||||
@@ -107,7 +128,7 @@ class ChatViewModelTest {
|
|||||||
createReqId
|
createReqId
|
||||||
}
|
}
|
||||||
|
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Trigger GatewayReady -> triggers createNewSession
|
// Trigger GatewayReady -> triggers createNewSession
|
||||||
@@ -135,7 +156,7 @@ class ChatViewModelTest {
|
|||||||
listReqId
|
listReqId
|
||||||
}
|
}
|
||||||
|
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||||
@@ -169,7 +190,7 @@ class ChatViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testMessageStreamingFlow() =
|
fun testMessageStreamingFlow() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Set active session ID first
|
// Set active session ID first
|
||||||
@@ -242,7 +263,7 @@ class ChatViewModelTest {
|
|||||||
createReqId
|
createReqId
|
||||||
}
|
}
|
||||||
|
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Set session ID
|
// Set session ID
|
||||||
@@ -276,7 +297,7 @@ class ChatViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testSendMessage() =
|
fun testSendMessage() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Trigger GatewayReady -> triggers createSession -> feed result to set active session
|
// Trigger GatewayReady -> triggers createSession -> feed result to set active session
|
||||||
@@ -309,7 +330,7 @@ class ChatViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testSwitchSession() =
|
fun testSwitchSession() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
viewModel.switchSession("session-456")
|
viewModel.switchSession("session-456")
|
||||||
@@ -339,7 +360,7 @@ class ChatViewModelTest {
|
|||||||
createReqId
|
createReqId
|
||||||
}
|
}
|
||||||
|
|
||||||
val viewModel = ChatViewModel()
|
val viewModel = ChatViewModel(app)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ junit = "4.13.2"
|
|||||||
junit5 = "5.11.4"
|
junit5 = "5.11.4"
|
||||||
junitPlatform = "1.11.4"
|
junitPlatform = "1.11.4"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.20"
|
||||||
|
ksp = "2.3.9"
|
||||||
nav3Core = "1.0.1"
|
nav3Core = "1.0.1"
|
||||||
lifecycleViewmodelNav3 = "2.10.0"
|
lifecycleViewmodelNav3 = "2.10.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
@@ -20,6 +21,8 @@ retrofit = "2.11.0"
|
|||||||
gson = "2.11.0"
|
gson = "2.11.0"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
mockk = "1.13.12"
|
mockk = "1.13.12"
|
||||||
|
room = "2.7.1"
|
||||||
|
workManager = "2.10.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||||
@@ -61,7 +64,17 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
|||||||
# Security
|
# Security
|
||||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
|
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
|
||||||
|
|
||||||
|
# Database (Room)
|
||||||
|
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
|
||||||
|
# Background notifications (WorkManager)
|
||||||
|
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
room = { id = "androidx.room", version.ref = "room" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
Reference in new issue
Block a user