feat: add setting to keep connection alive in background (#1196)

This commit is contained in:
M57 authored and GitHub committed 2026-09-19 18:28:48 +03:00
1 parent a61a1b9974
commit 759d59b9f9
19 files changed
+1155 -24

No files matched your search

@@ -0,0 +1,115 @@
package com.m57.hermescontrol.notification
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.ws.HermesWsClient
/**
* Coordinates background connection leases and foreground service lifecycle
* based on [BackgroundConnectionPolicy].
*/
class BackgroundConnectionController(
private val snapshotProvider: (isDeparting: Boolean) -> BackgroundConnectionSnapshot = { isDeparting ->
defaultSnapshot(isDeparting)
},
private val acquireLease: () -> Unit = { HermesWsClient.acquireBackgroundConnectionLease() },
private val releaseLease: () -> Unit = { HermesWsClient.releaseBackgroundConnectionLease() },
private val requestServiceStart: (() -> Unit) -> Unit = { startAction ->
ChatNotificationService.lifecycle.start(startAction)
},
private val requestServiceStop: () -> Unit = { ChatNotificationService.lifecycle.stop() },
private val requestServiceComplete: (Long) -> Unit = { generation ->
ChatNotificationService.lifecycle.complete(generation)
},
private val onNotificationStateChanged: (BackgroundNotificationState) -> Unit = { state ->
ChatNotificationService.updateForegroundNotification(state)
},
) {
companion object {
private const val TAG = "BgConnectionController"
val default: BackgroundConnectionController by lazy { BackgroundConnectionController() }
fun defaultSnapshot(isDeparting: Boolean = false): BackgroundConnectionSnapshot {
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) {
return BackgroundConnectionSnapshot.ineligible(
appInForeground = ChatNotificationService.isAppInForeground(),
isDeparting = isDeparting,
)
}
val status = HermesWsClient.connectionStatus.value
val isEligible = AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()
return BackgroundConnectionSnapshot(
appInForeground = ChatNotificationService.isAppInForeground(),
isDeparting = isDeparting,
keepConnectedOptIn = AuthManager.isKeepConnectedInBackground(),
pendingReply = HermesWsClient.pendingReply,
isEligibleForConnection = isEligible,
status = status,
isAutoReconnect = AuthManager.isAutoReconnect(),
hasActiveNetwork = NetworkMonitor.isConnected.value,
)
}
}
private var currentNotificationState: BackgroundNotificationState = BackgroundNotificationState.None
fun onAppPause(startServiceAction: () -> Unit) {
val snapshot = snapshotProvider(true)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (!decision.shouldHoldService) {
return
}
if (decision.shouldHoldPersistentLease) {
acquireLease()
}
try {
requestServiceStart(startServiceAction)
} catch (e: Exception) {
Log.w(TAG, "Failed to start background service, rolling back lease", e)
if (decision.shouldHoldPersistentLease) {
releaseLease()
}
}
}
fun onAppResume() {
releaseLease()
requestServiceStop()
}
fun onReplyCompleted(generation: Long) {
val snapshot = snapshotProvider(false).copy(pendingReply = false)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (decision.shouldHoldService) {
if (decision.notificationState != currentNotificationState) {
currentNotificationState = decision.notificationState
onNotificationStateChanged(decision.notificationState)
}
} else {
requestServiceComplete(generation)
}
}
fun reconcileState() {
val snapshot = snapshotProvider(false)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
if (!decision.shouldHoldService && !snapshot.appInForeground) {
releaseLease()
requestServiceStop()
} else if (decision.shouldHoldService) {
if (decision.notificationState != currentNotificationState) {
currentNotificationState = decision.notificationState
onNotificationStateChanged(decision.notificationState)
}
}
}
}
@@ -0,0 +1,141 @@
package com.m57.hermescontrol.notification
import com.m57.hermescontrol.data.ws.ConnectionStatus
/**
* Snapshot of runtime state required to determine background connection policy.
*/
data class BackgroundConnectionSnapshot(
val appInForeground: Boolean,
val isDeparting: Boolean = false,
val keepConnectedOptIn: Boolean,
val pendingReply: Boolean,
val isEligibleForConnection: Boolean,
val status: ConnectionStatus,
val isAutoReconnect: Boolean = true,
val hasActiveNetwork: Boolean = true,
) {
companion object {
fun ineligible(
appInForeground: Boolean = false,
isDeparting: Boolean = false,
): BackgroundConnectionSnapshot =
BackgroundConnectionSnapshot(
appInForeground = appInForeground,
isDeparting = isDeparting,
keepConnectedOptIn = false,
pendingReply = false,
isEligibleForConnection = false,
status = ConnectionStatus.DISCONNECTED,
isAutoReconnect = false,
hasActiveNetwork = false,
)
}
}
/**
* User-visible state of the ongoing background notification.
*/
enum class BackgroundNotificationState {
None,
WaitingForNetwork,
Connecting,
Reconnecting,
WaitingForReplies,
ConnectedInBackground,
}
/**
* Evaluated decision for foreground service and connection retention.
*/
data class BackgroundConnectionDecision(
val shouldHoldService: Boolean,
val shouldHoldPersistentLease: Boolean,
val notificationState: BackgroundNotificationState,
)
/**
* Pure policy evaluation for background connection lifecycle.
*/
object BackgroundConnectionPolicy {
fun evaluate(snapshot: BackgroundConnectionSnapshot): BackgroundConnectionDecision {
// Terminal failure / unauthenticated / ineligible state
if (!snapshot.isEligibleForConnection || snapshot.status == ConnectionStatus.AUTH_EXPIRED) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// When the app is in foreground and not departing, background service/lease are not needed
if (snapshot.appInForeground && !snapshot.isDeparting) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// Demand check: need either a pending reply or persistent opt-in
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
if (!hasDemand) {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
// When Auto-Reconnect is disabled, HermesWsClient will not reconnect once disconnected or offline.
// Even if a reply was pending, it can never arrive without reconnecting.
if (!snapshot.isAutoReconnect) {
when (snapshot.status) {
ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
-> {
return BackgroundConnectionDecision(
shouldHoldService = false,
shouldHoldPersistentLease = false,
notificationState = BackgroundNotificationState.None,
)
}
else -> { /* CONNECTED, CONNECTING, RECONNECTING can proceed */ }
}
}
val notificationState =
when {
!snapshot.hasActiveNetwork || snapshot.status == ConnectionStatus.NO_NETWORK -> {
BackgroundNotificationState.WaitingForNetwork
}
snapshot.status == ConnectionStatus.RECONNECTING -> {
BackgroundNotificationState.Reconnecting
}
snapshot.pendingReply -> {
BackgroundNotificationState.WaitingForReplies
}
snapshot.status == ConnectionStatus.CONNECTED -> {
BackgroundNotificationState.ConnectedInBackground
}
snapshot.status == ConnectionStatus.CONNECTING -> {
BackgroundNotificationState.Connecting
}
else -> {
BackgroundNotificationState.Reconnecting
}
}
return BackgroundConnectionDecision(
shouldHoldService = true,
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
notificationState = notificationState,
)
}
}
@@ -14,6 +14,7 @@ import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -43,16 +44,16 @@ import java.util.concurrent.atomic.AtomicBoolean
* message notifications, which is correct.
*
* Lifecycle:
* - 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 MainActivity.onStart), or by the
* service itself once the pending reply completes in the background
* (the reply notification replaces the persistent "waiting" one).
* - Started by [NotificationHelper.start] when the app goes to the background
* either while a reply is still pending (issue #794) or when persistent background
* connection ([com.m57.hermescontrol.data.local.AuthManager.isKeepConnectedInBackground])
* is enabled.
* - In persistent keep-connected mode, the service stays alive across idle states
* and updates its ongoing notification truthfully ([BackgroundNotificationState]).
* - In replies-only mode (default), the service retires itself once the pending reply
* completes in the background.
* - Stopped by [NotificationHelper.stop] when the app returns to the foreground, or
* retired automatically upon auth expiry or terminal disconnection.
*
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
@@ -68,20 +69,30 @@ class ChatNotificationService : Service() {
private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle()
@Volatile
internal var activeServiceInstance: ChatNotificationService? = null
fun setAppForeground(foreground: Boolean) {
isAppInForeground.set(foreground)
}
fun isAppInForeground(): Boolean = isAppInForeground.get()
fun updateForegroundNotification(state: BackgroundNotificationState) {
activeServiceInstance?.updateNotification(state)
}
}
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var eventCollector: Job? = null
private var statusCollector: Job? = null
override fun onCreate() {
super.onCreate()
activeServiceInstance = this
createNotificationChannels()
startEventCollection()
startStatusCollection()
}
private fun startEventCollection() {
@@ -113,7 +124,7 @@ class ChatNotificationService : Service() {
// service is not restarted on the next
// ON_STOP (issue #794).
// A delayed completion must not retire a newer turn/start.
if (!HermesWsClient.pendingReply) lifecycle.complete(generation)
BackgroundConnectionController.default.onReplyCompleted(generation)
}
is WsEvent.ClarifyRequest -> {
@@ -269,10 +280,13 @@ class ChatNotificationService : Service() {
flags: Int,
startId: Int,
): Int {
val initialDecision =
BackgroundConnectionPolicy.evaluate(BackgroundConnectionController.defaultSnapshot(isDeparting = true))
val initialText = resolveNotificationText(initialDecision.notificationState)
lifecycle.onStart(
owner = this,
promote = {
startForeground(NOTIFICATION_ID, buildForegroundNotification(getString(R.string.notif_waiting_replies)))
startForeground(NOTIFICATION_ID, buildForegroundNotification(initialText))
},
// Do not remove foreground status before Android accepts retirement:
// a newer start may already be queued, but not delivered to us yet.
@@ -284,11 +298,52 @@ class ChatNotificationService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
if (activeServiceInstance === this) {
activeServiceInstance = null
}
lifecycle.onDestroyed(this)
eventCollector?.cancel()
statusCollector?.cancel()
serviceScope.cancel()
super.onDestroy()
}
private fun startStatusCollection() {
statusCollector =
serviceScope.launch {
launch {
NetworkMonitor.networkChanges.collect {
if (!isAppInForeground.get()) {
BackgroundConnectionController.default.reconcileState()
}
}
}
launch {
HermesWsClient.connectionStatus.collect {
if (!isAppInForeground.get()) {
BackgroundConnectionController.default.reconcileState()
}
}
}
}
}
internal fun updateNotification(state: BackgroundNotificationState) {
if (isAppInForeground.get()) return
val text = resolveNotificationText(state)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
manager.notify(NOTIFICATION_ID, buildForegroundNotification(text))
}
private fun resolveNotificationText(state: BackgroundNotificationState): String =
when (state) {
BackgroundNotificationState.WaitingForNetwork -> getString(R.string.notif_waiting_network)
BackgroundNotificationState.Connecting -> getString(R.string.notif_connecting)
BackgroundNotificationState.Reconnecting -> getString(R.string.notif_reconnecting)
BackgroundNotificationState.WaitingForReplies -> getString(R.string.notif_waiting_replies)
BackgroundNotificationState.ConnectedInBackground -> getString(R.string.notif_connected_in_background)
BackgroundNotificationState.None -> getString(R.string.notif_waiting_replies)
}
}
/**
@@ -296,15 +351,8 @@ class ChatNotificationService : Service() {
*/
object NotificationHelper {
fun start(context: Context) {
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) return
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)
ChatNotificationService.lifecycle.start {
BackgroundConnectionController.default.onAppPause {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@@ -314,9 +362,7 @@ object NotificationHelper {
}
fun stop(context: Context) {
// Rotation can resume the activity before the queued service is created.
// Direct stopService here crashes Android while foreground promotion is owed.
ChatNotificationService.lifecycle.stop()
BackgroundConnectionController.default.onAppResume()
}
fun setAppForeground(
@@ -325,6 +371,9 @@ object NotificationHelper {
) {
ChatNotificationService.setAppForeground(foreground)
HermesWsClient.setAppForeground(foreground)
if (!foreground) {
BackgroundConnectionController.default.reconcileState()
}
}
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()