diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index c3e5b137..d202a8f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -157,6 +157,15 @@ object HermesWsClient { /** Observable connection status */ val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + // ── Reply-pending tracking ───────────────────────────────────────── + // True while at least one agent turn is in flight. Drives the chat + // notification foreground service: it only runs while a reply is + // actually pending, instead of for the whole time the app is + // backgrounded (issue #794). + @Volatile + var pendingReply: Boolean = false + private set + // ── Credential warning (issue #534) ───────────────────────────────── // Backend surfaces `credential_warning` in `gateway.ready` / `session.info` // WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent @@ -207,6 +216,25 @@ object HermesWsClient { } } } + // Track whether a reply is actually in flight so the chat notification + // foreground service only runs while the user is waiting for one + // (issue #794), instead of for the whole time the app is backgrounded. + // Also covers turns started from other devices on the same session. + wsScope.launch { + events.collect { event -> + when (event) { + is WsEvent.MessageStart, + is WsEvent.MessageToken, + is WsEvent.ThinkingDelta, + is WsEvent.ReasoningDelta, + is WsEvent.ToolStart, + -> pendingReply = true + + is WsEvent.MessageComplete -> pendingReply = false + else -> {} + } + } + } } // ── Connection helpers ──────────────────────────────────────────────── @@ -584,12 +612,17 @@ object HermesWsClient { sessionId: String, text: String, onSent: ((String) -> Unit)? = null, - ): String = - send( + ): String { + // A reply is now pending — even before the first MessageStart event + // (the agent may be in its tool phase), so backgrounding the app in + // that window still arms the notification service (issue #794). + pendingReply = true + return send( method = WsMethods.PROMPT_SUBMIT, params = mapOf("session_id" to sessionId, "text" to text), onSent = onSent, ) + } /** * Convenience: redirect the active model turn while it is still generating diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 71af6dfe..a90b8081 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -42,11 +42,16 @@ import java.util.concurrent.atomic.AtomicBoolean * message notifications, which is correct. * * 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). + * - Started by [NotificationHelper.start] when the app goes to the + * background while a reply is still pending (the user sent a message or + * replied from a notification and the agent has not finished yet). + * [NotificationHelper.start] is a no-op when nothing is pending, so the + * service — and its mandatory persistent notification — only exists + * while the user is actually waiting for a reply (issue #794). * - Stopped by [NotificationHelper.stop] when the app returns to the - * foreground (called from ChatScreen's onStart / onResume). + * foreground (called from ChatScreen's onStart / onResume), or by the + * service itself once the pending reply completes in the background + * (the reply notification replaces the persistent "waiting" one). * * The service collects [WsEvent]s from [HermesWsClient] — the same stream * the ChatViewModel collects — and watches for [WsEvent.MessageComplete] @@ -64,6 +69,8 @@ class ChatNotificationService : Service() { fun setAppForeground(foreground: Boolean) { isAppInForeground.set(foreground) } + + fun isAppInForeground(): Boolean = isAppInForeground.get() } private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -92,6 +99,15 @@ class ChatNotificationService : Service() { .replace("\n", " ") .ifBlank { getString(R.string.notif_new_message) } showReplyNotification(preview, event.sessionId) + // The wait is over — retire the foreground + // service. The reply notification above + // replaces the persistent "waiting" one, + // and the pendingReply flag is cleared by + // HermesWsClient's own collector, so the + // service is not restarted on the next + // ON_STOP (issue #794). + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() } is WsEvent.ClarifyRequest -> { @@ -235,6 +251,11 @@ class ChatNotificationService : Service() { object NotificationHelper { fun start(context: Context) { if (AuthManager.getToken().isNullOrBlank()) return + // Only run the foreground service while a reply is actually pending + // (issue #794) — otherwise the mandatory persistent "Waiting for + // Hermes replies" notification appears on every background even + // when nothing is in flight. + if (!HermesWsClient.pendingReply) return val intent = Intent(context, ChatNotificationService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) @@ -253,4 +274,6 @@ object NotificationHelper { ) { ChatNotificationService.setAppForeground(foreground) } + + fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground() } diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index d391ebb1..6baecda7 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -92,6 +92,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() { val repliedNotification = buildReplyNotification(context) val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification) + + // The follow-up turn is now pending — keep the + // foreground service alive if it retired after the + // previous reply completed, so the next + // MessageComplete still notifies while backgrounded + // (issue #794). No-op when the app is foreground. + if (!NotificationHelper.isAppInForeground()) { + NotificationHelper.start(context) + } } } } catch (e: Exception) {