From 5130c2e3084f3a2be38835e253abe20a20cea629 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:23:38 +0300 Subject: [PATCH] feat(chat): message persistence + background notifications (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/workflows/release.yml | 124 ++++++++++++ app/build.gradle.kts | 23 ++- app/proguard-rules.pro | 48 +++++ app/src/main/AndroidManifest.xml | 8 + .../data/local/ChatMessageDao.kt | 47 +++++ .../data/local/ChatMessageEntity.kt | 33 ++++ .../data/local/ChatMessageMapper.kt | 47 +++++ .../data/local/HermesDatabase.kt | 37 ++++ .../notification/ChatNotificationService.kt | 178 ++++++++++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 33 +++- .../hermescontrol/ui/chat/ChatViewModel.kt | 137 ++++++++++---- .../ui/chat/ChatViewModelTest.kt | 39 +++- gradle/libs.versions.toml | 15 +- 13 files changed, 722 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c38b85b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78b923c3..22a1c32a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.room) + alias(libs.plugins.ksp) } android { @@ -13,13 +15,16 @@ android { applicationId = "com.m57.hermescontrol" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + // Version overrides passed from CI via -PversionName / -PversionCode + // 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 { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") } @@ -46,6 +51,10 @@ kotlin { jvmToolchain(17) } +room { + schemaDirectory("$projectDir/schemas") +} + dependencies { val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) @@ -82,6 +91,14 @@ dependencies { implementation(libs.androidx.security.crypto) 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 testImplementation(libs.junit) testImplementation(libs.junit.jupiter.api) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..d9a70b7b --- /dev/null +++ b/app/proguard-rules.pro @@ -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.* ; } +-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.* ; +} + +# ── AndroidX Security Crypto ───────────────────────────────────────────── +-dontwarn androidx.security.crypto.** + +# ── Compose ─────────────────────────────────────────────────────────────── +-dontwarn androidx.compose.** + +# ── BuildConfig ─────────────────────────────────────────────────────────── +-keep class com.m57.hermescontrol.BuildConfig { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c5b61a6..86992106 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + + + diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt new file mode 100644 index 00000000..9a78fb7f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -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> + + @Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC") + suspend fun getMessagesForSession(sessionId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(message: ChatMessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(messages: List) + + @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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt new file mode 100644 index 00000000..730ca9b7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt new file mode 100644 index 00000000..b3876f62 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -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, + ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt new file mode 100644 index 00000000..c6cfeb89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt new file mode 100644 index 00000000..38714b49 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 8400244b..b12ee875 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 0f89c7e7..e465c68c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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, ) -class ChatViewModel : ViewModel() { +class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val wsClient = HermesWsClient private val pendingRequests = mutableMapOf() // 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() { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 2445c262..7660e9dd 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -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(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)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a43a1a6..cee503f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ junit = "4.13.2" junit5 = "5.11.4" junitPlatform = "1.11.4" kotlin = "2.3.20" +ksp = "2.3.9" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" okhttp = "4.12.0" @@ -20,6 +21,8 @@ retrofit = "2.11.0" gson = "2.11.0" securityCrypto = "1.1.0-alpha06" mockk = "1.13.12" +room = "2.7.1" +workManager = "2.10.0" [libraries] 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 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] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +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" } \ No newline at end of file