From 759d59b9f9ff172b134323906e47d63fe60ea366 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:28:48 +0300 Subject: [PATCH] feat: add setting to keep connection alive in background (#1196) --- .../data/config/ServerStoreState.kt | 1 + .../hermescontrol/data/local/AuthManager.kt | 6 + .../hermescontrol/data/ws/HermesWsClient.kt | 15 + .../BackgroundConnectionController.kt | 115 +++++ .../BackgroundConnectionPolicy.kt | 141 ++++++ .../notification/ChatNotificationService.kt | 95 +++- .../ui/settings/SettingsScreen.kt | 4 +- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 9 + .../ui/settings/components/BehaviorSection.kt | 35 ++ app/src/main/res/values-ar/strings.xml | 6 + app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-zh/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../data/config/ServerStoreTest.kt | 16 + .../data/ws/HermesWsClientTest.kt | 19 + .../BackgroundConnectionControllerTest.kt | 410 ++++++++++++++++++ .../BackgroundConnectionPolicyTest.kt | 253 +++++++++++ .../ui/settings/SettingsViewModelTest.kt | 34 ++ 19 files changed, 1155 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index b5fd31cf..931dd246 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -29,6 +29,7 @@ data class ServerStoreState( val showAssistantMessageTokens: Boolean = true, val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, + val keepConnectedInBackground: Boolean = false, // App display language. "system" = follow device locale; otherwise a BCP-47 // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. val appLanguage: String = "system", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index ec338ffc..a9a12d33 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -856,6 +856,12 @@ object AuthManager { serverStore.update { it.copy(showModelProvider = shown) } } + fun isKeepConnectedInBackground(): Boolean = serverStore.getLatestState().keepConnectedInBackground + + fun setKeepConnectedInBackground(enabled: Boolean) { + serverStore.update { it.copy(keepConnectedInBackground = enabled) } + } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale 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 042e5bf1..57b98eef 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 @@ -123,6 +123,7 @@ object HermesWsClient { private val acceptQueuedMessages = AtomicBoolean(true) private val appInForeground = AtomicBoolean(true) private val externalActivityConnectionLease = AtomicBoolean(false) + private val backgroundConnectionLease = AtomicBoolean(false) private val messageQueue = ConcurrentLinkedQueue() private val queuedMessagesById = ConcurrentHashMap() private val outboundLock = Any() @@ -433,10 +434,23 @@ object HermesWsClient { disconnectIfIdleInBackground() } + fun acquireBackgroundConnectionLease() { + backgroundConnectionLease.set(true) + } + + fun releaseBackgroundConnectionLease() { + backgroundConnectionLease.set(false) + disconnectIfIdleInBackground() + } + + @VisibleForTesting + internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get() + private fun disconnectIfIdleInBackground() { synchronized(outboundLock) { if (!appInForeground.get() && !externalActivityConnectionLease.get() && + !backgroundConnectionLease.get() && !pendingReply && pendingCalls.isEmpty() && messageQueue.isEmpty() @@ -708,6 +722,7 @@ object HermesWsClient { webSocket = null closingSocket = null if (clearPendingMessages) { + backgroundConnectionLease.set(false) messageQueue.clear() queuedMessagesById.clear() pendingPromptSubmits.clear() diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt new file mode 100644 index 00000000..01ba66a4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt new file mode 100644 index 00000000..693108ad --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt @@ -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, + ) + } +} 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 1026ad08..1feba748 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 7e4d9ce3..6ea47d43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -114,7 +114,9 @@ fun SettingsScreen( icon = Icons.Filled.Tune, label = stringResource(R.string.settings_sec_behavior), summary = - if (state.autoReconnect || state.restoreLastSession) { + if (state.autoReconnect || state.restoreLastSession || + state.keepConnectedInBackground + ) { stringResource(R.string.settings_summary_on) } else { stringResource(R.string.settings_summary_off) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 2450d723..b9470d4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -237,6 +237,8 @@ internal fun SettingsBehaviorPage( BehaviorSection( autoReconnect = state.autoReconnect, onAutoReconnectChange = viewModel::onAutoReconnectChange, + keepConnectedInBackground = state.keepConnectedInBackground, + onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, restoreLastSession = state.restoreLastSession, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 59c5336a..624efb1d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -44,6 +44,7 @@ data class SettingsUiState( val showAssistantMessageTokens: Boolean = true, val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, + val keepConnectedInBackground: Boolean = false, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -91,6 +92,7 @@ class SettingsViewModel( val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled() val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val showModelProvider = AuthManager.isModelProviderShown() + val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -117,6 +119,7 @@ class SettingsViewModel( showAssistantMessageTokens = showAssistantMessageTokens, showTokensPerSecond = showTokensPerSecond, showModelProvider = showModelProvider, + keepConnectedInBackground = keepConnectedInBackground, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -368,6 +371,11 @@ class SettingsViewModel( AuthManager.setModelProviderShown(enabled) } + fun onKeepConnectedInBackgroundChange(enabled: Boolean) { + _uiState.update { it.copy(keepConnectedInBackground = enabled) } + AuthManager.setKeepConnectedInBackground(enabled) + } + /** Clear all auth credentials — logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) @@ -402,6 +410,7 @@ class SettingsViewModel( AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens) AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond) AuthManager.setModelProviderShown(state.showModelProvider) + AuthManager.setKeepConnectedInBackground(state.keepConnectedInBackground) ApiClient.rebuild() viewModelScope.launch(ioDispatcher) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index 1bb9bd17..7eca2657 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -23,6 +23,8 @@ import com.m57.hermescontrol.ui.settings.SectionCard internal fun BehaviorSection( autoReconnect: Boolean, onAutoReconnectChange: (Boolean) -> Unit, + keepConnectedInBackground: Boolean, + onKeepConnectedInBackgroundChange: (Boolean) -> Unit, restoreLastSession: Boolean, onRestoreLastSessionChange: (Boolean) -> Unit, ) { @@ -60,6 +62,39 @@ internal fun BehaviorSection( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_keep_connected_in_background_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_keep_connected_in_background_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = keepConnectedInBackground, + onCheckedChange = onKeepConnectedInBackgroundChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 569fa1be..02399916 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -128,6 +128,10 @@ الملفات Hermes بانتظار ردود Hermes +متصل في الخلفية +جارٍ الاتصال… +في انتظار الشبكة +جارٍ إعادة الاتصال رسالة جديدة يحتاج Hermes إلى توضيح اكتب ردك\u2026 @@ -177,6 +181,8 @@ تعذر التقاط الصورة الاتصال رمز المصادقة (Token) +البقاء متصلاً في الخلفية +إبقاء هيرمس متصلاً أثناء استخدام تطبيقات أخرى. يعرض إشعاراً مستمراً وقد يستهلك المزيد من البطارية. السلوك إعادة الاتصال التلقائي إعادة الاتصال تلقائيًا عند انقطاعه diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b22dc93a..cb0ac616 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -125,6 +125,10 @@ 헤르메스 헤르메스 응답 대기 중 + 백그라운드에서 연결됨 + 연결 중… + 네트워크 대기 중 + 재연결 중 새 메시지 헤르메스의 확인이 필요합니다 답변 입력… @@ -180,6 +184,8 @@ 연결 인증 토큰 + 백그라운드에서 연결 유지 + 다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다. 동작 자동 재연결 연결 끊김 시 자동 재연결 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cb5ea892..9947b4d6 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -128,6 +128,10 @@ 文件 Hermes 等待 Hermes 回复 + 在后台保持连接 + 正在连接… + 等待网络连接 + 正在重新连接 新消息 Hermes 需要确认 输入回复… @@ -177,6 +181,8 @@ 拍照失败 连接 认证令牌 + 在后台保持连接 + 在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。 行为 自动重连 断开后自动重新连接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f076cde8..f5909224 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,10 @@ Hermes Waiting for Hermes replies + Connected in background + Connecting… + Waiting for network + Reconnecting New message Hermes needs a clarification Hermes needs your input @@ -201,6 +205,8 @@ Connection Auth Token + Stay connected in background + Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. Behavior Auto-Reconnect Automatically reconnect on disconnect diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt index bb694ae7..db3c4e73 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -18,6 +18,7 @@ class ServerStoreTest { assertEquals("token", state.wsAuthParam) assertEquals(1.0f, state.chatFontScale) assertFalse(state.showModelProvider) + assertFalse(state.keepConnectedInBackground) assertTrue(state.connectionProfiles.isEmpty()) assertNull(state.selectedProfileId) } @@ -77,4 +78,19 @@ class ServerStoreTest { val updated = state.copy(showModelProvider = true) assertTrue(updated.showModelProvider) } + + @Test + fun testKeepConnectedInBackgroundUpdate() { + val state = ServerStoreState() + assertFalse(state.keepConnectedInBackground) + val updated = state.copy(keepConnectedInBackground = true) + assertTrue(updated.keepConnectedInBackground) + } + + @Test + fun testKeepConnectedInBackground_deserializationDefault() { + val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + val decoded = json.decodeFromString("{}") + assertFalse(decoded.keepConnectedInBackground) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 6e2016f0..1de377e9 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -106,6 +106,7 @@ class HermesWsClientTest { HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.setAppForeground(true) val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") acceptQueuedMessagesField.isAccessible = true @@ -115,6 +116,7 @@ class HermesWsClientTest { @After fun tearDown() { HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.disconnect(clearPendingMessages = true) // Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer // Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake @@ -973,6 +975,23 @@ class HermesWsClientTest { assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) } + @Test + fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() { + mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) + HermesWsClient.connect() + runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } + + HermesWsClient.acquireBackgroundConnectionLease() + HermesWsClient.setAppForeground(false) + + assertTrue(HermesWsClient.isConnected) + + HermesWsClient.releaseBackgroundConnectionLease() + + assertFalse(HermesWsClient.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) + } + @Test fun testBackgroundWithPendingReplyStaysConnected() { mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt new file mode 100644 index 00000000..2a641154 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -0,0 +1,410 @@ +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.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BackgroundConnectionControllerTest { + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.w(any(), any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + } + + @After + fun tearDown() { + unmockkStatic(Log::class) + } + + @Test + fun testOnAppPause_whenOptIn_acquiresLeaseAndRequestsStart() { + var leaseAcquired = false + var serviceStartRequested = false + var startActionInvoked = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { action -> + serviceStartRequested = true + action() + }, + ) + + controller.onAppPause { + startActionInvoked = true + } + + assertTrue(leaseAcquired) + assertTrue(serviceStartRequested) + assertTrue(startActionInvoked) + } + + @Test + fun testOnAppPause_whenStartThrowsException_rollsBackLeaseAndDoesNotRethrow() { + var leaseAcquired = false + var leaseReleased = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseReleased = true }, + requestServiceStart = { _ -> + throw IllegalStateException("Background start not allowed") + }, + ) + + // Must not rethrow into Activity.onPause, but must roll back acquired lease + controller.onAppPause {} + + assertTrue(leaseAcquired) + assertTrue(leaseReleased) + } + + @Test + fun testOnAppPause_whenOptInOffAndNoPendingReply_doesNothing() { + var leaseAcquired = false + var serviceStartRequested = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { serviceStartRequested = true }, + ) + + controller.onAppPause {} + + assertFalse(leaseAcquired) + assertFalse(serviceStartRequested) + } + + @Test + fun testOnAppPause_whenPendingReplyOnly_requestsServiceWithoutPersistentLease() { + var leaseAcquired = false + var serviceStartRequested = false + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { isDeparting -> snapshot.copy(isDeparting = isDeparting) }, + acquireLease = { leaseAcquired = true }, + releaseLease = { leaseAcquired = false }, + requestServiceStart = { serviceStartRequested = true }, + ) + + controller.onAppPause {} + + assertFalse(leaseAcquired) + assertTrue(serviceStartRequested) + } + + @Test + fun testOnAppResume_releasesLeaseAndStopsService() { + var leaseReleased = false + var serviceStopped = false + + val controller = + BackgroundConnectionController( + releaseLease = { leaseReleased = true }, + requestServiceStop = { serviceStopped = true }, + ) + + controller.onAppResume() + + assertTrue(leaseReleased) + assertTrue(serviceStopped) + } + + @Test + fun testOnReplyCompleted_whenOptInOn_keepsServiceAndUpdatesNotification() { + var serviceCompleted = false + var notifiedState: BackgroundNotificationState? = null + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + requestServiceComplete = { serviceCompleted = true }, + onNotificationStateChanged = { state -> notifiedState = state }, + ) + + controller.onReplyCompleted(42L) + + assertFalse(serviceCompleted) + assertEquals(BackgroundNotificationState.ConnectedInBackground, notifiedState) + } + + @Test + fun testOnReplyCompleted_whenOptInOff_completesService() { + var completedGeneration: Long? = null + + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + requestServiceComplete = { gen -> completedGeneration = gen }, + ) + + controller.onReplyCompleted(42L) + + assertEquals(42L, completedGeneration) + } + + @Test + fun testDefaultSnapshot_whenNotReady_returnsIneligibleSnapshotWithoutReadingServerStore() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Loading) + every { ChatNotificationService.isAppInForeground() } returns true + + val snapshot = BackgroundConnectionController.defaultSnapshot() + + assertFalse(snapshot.isEligibleForConnection) + assertFalse(snapshot.keepConnectedOptIn) + assertFalse(snapshot.pendingReply) + assertEquals(ConnectionStatus.DISCONNECTED, snapshot.status) + + // Must NOT call any serverStore getters before Ready + verify(exactly = 0) { AuthManager.isGatedMode() } + verify(exactly = 0) { AuthManager.getToken() } + verify(exactly = 0) { AuthManager.isKeepConnectedInBackground() } + verify(exactly = 0) { AuthManager.isAutoReconnect() } + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenReadyGatedModeWithNullToken_isEligibleForConnection() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns true + every { AuthManager.getToken() } returns null + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.CONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.CONNECTED, snapshot.status) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenReadyNotGatedModeAndNullToken_isNotEligible() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns null + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.DISCONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertFalse(snapshot.isEligibleForConnection) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenAuthExpired_policyReleasesService() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns "some-token" + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns true + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.AUTH_EXPIRED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.AUTH_EXPIRED, snapshot.status) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } + + @Test + fun testDefaultSnapshot_whenAutoReconnectDisabledAndDisconnected_policyReleasesService() { + mockkObject(AuthManager) + mockkObject(ChatNotificationService) + mockkObject(HermesWsClient) + mockkObject(NetworkMonitor) + try { + every { AuthManager.initializationState } returns MutableStateFlow(AuthManager.InitializationState.Ready) + every { AuthManager.isGatedMode() } returns false + every { AuthManager.getToken() } returns "some-token" + every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isAutoReconnect() } returns false + every { ChatNotificationService.isAppInForeground() } returns false + every { HermesWsClient.pendingReply } returns false + every { HermesWsClient.connectionStatus } returns MutableStateFlow(ConnectionStatus.DISCONNECTED) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + + val snapshot = BackgroundConnectionController.defaultSnapshot() + assertTrue(snapshot.isEligibleForConnection) + assertEquals(ConnectionStatus.DISCONNECTED, snapshot.status) + assertFalse(snapshot.isAutoReconnect) + + val decision = BackgroundConnectionPolicy.evaluate(snapshot) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } finally { + unmockkObject(AuthManager) + unmockkObject(ChatNotificationService) + unmockkObject(HermesWsClient) + unmockkObject(NetworkMonitor) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt new file mode 100644 index 00000000..fda85801 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt @@ -0,0 +1,253 @@ +package com.m57.hermescontrol.notification + +import com.m57.hermescontrol.data.ws.ConnectionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackgroundConnectionPolicyTest { + @Test + fun testForeground_hasNoServiceDemand() { + val state = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testIneligible_winsOverAllDemand() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = false, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testBackground_optInOff_noPendingReply_noService() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testBackground_optInOff_pendingReply_holdsServiceRepliesOnly() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } + + @Test + fun testBackground_optInOn_noPendingReply_holdsServiceAndPersistentLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } + + @Test + fun testBackground_optInOn_pendingReply_holdsBoth_notificationPrefersWaiting() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } + + @Test + fun testDeparting_holdsServiceBeforeBackgroundCompletes() { + val state = + BackgroundConnectionSnapshot( + appInForeground = true, + isDeparting = true, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + } + + @Test + fun testBackground_noNetwork_whenAutoReconnectEnabled_showsWaitingForNetwork() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.NO_NETWORK, + isAutoReconnect = true, + hasActiveNetwork = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertEquals(BackgroundNotificationState.WaitingForNetwork, decision.notificationState) + } + + @Test + fun testBackground_reconnecting_showsReconnecting() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.RECONNECTING, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertEquals(BackgroundNotificationState.Reconnecting, decision.notificationState) + } + + @Test + fun testAuthExpired_releasesServiceAndLease_showsNone() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.AUTH_EXPIRED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testDisconnected_whenAutoReconnectDisabled_releasesServiceAndLease_evenWithPendingReply() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.DISCONNECTED, + isAutoReconnect = false, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testNoNetwork_whenAutoReconnectDisabled_releasesServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.NO_NETWORK, + isAutoReconnect = false, + hasActiveNetwork = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertFalse(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.None, decision.notificationState) + } + + @Test + fun testConnecting_whenAutoReconnectDisabled_allowsServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = true, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTING, + isAutoReconnect = false, + hasActiveNetwork = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.Connecting, decision.notificationState) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 40ec3506..ce4b388e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -78,6 +78,7 @@ class SettingsViewModelTest { every { AuthManager.isAssistantMessageTokensEnabled() } returns true every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isModelProviderShown() } returns false + every { AuthManager.isKeepConnectedInBackground() } returns false every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" @@ -95,6 +96,7 @@ class SettingsViewModelTest { every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit every { AuthManager.setModelProviderShown(any()) } returns Unit + every { AuthManager.setKeepConnectedInBackground(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } answers { storedSelectedProfileId = firstArg() } @@ -362,4 +364,36 @@ class SettingsViewModelTest { verify { AuthManager.setModelProviderShown(true) } assertEquals(true, viewModel.uiState.value.isSaved) } + + @Test + fun testKeepConnectedInBackground_loadsAndToggles() = + runTest { + every { AuthManager.isKeepConnectedInBackground() } returns false + + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + + viewModel.onKeepConnectedInBackgroundChange(true) + assertEquals(true, viewModel.uiState.value.keepConnectedInBackground) + verify { AuthManager.setKeepConnectedInBackground(true) } + + viewModel.onKeepConnectedInBackgroundChange(false) + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + verify { AuthManager.setKeepConnectedInBackground(false) } + } + + @Test + fun testSave_persistsKeepConnectedInBackground() = + runTest { + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onKeepConnectedInBackgroundChange(true) + viewModel.save() + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.setKeepConnectedInBackground(true) } + assertEquals(true, viewModel.uiState.value.isSaved) + } }