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

@@ -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",
@@ -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
@@ -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<String>()
private val queuedMessagesById = ConcurrentHashMap<String, String>()
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()
@@ -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()
@@ -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)
@@ -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,
)
@@ -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<ConnectionProfile> = 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) {
@@ -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,
+6
View File
@@ -128,6 +128,10 @@
<string name="screen_files">الملفات</string>
<string name="notif_title">Hermes</string>
<string name="notif_waiting_replies">بانتظار ردود Hermes</string>
<string name="notif_connected_in_background">متصل في الخلفية</string>
<string name="notif_connecting">جارٍ الاتصال…</string>
<string name="notif_waiting_network">في انتظار الشبكة</string>
<string name="notif_reconnecting">جارٍ إعادة الاتصال</string>
<string name="notif_new_message">رسالة جديدة</string>
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string>
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
@@ -177,6 +181,8 @@
<string name="chat_camera_error">تعذر التقاط الصورة</string>
<string name="settings_sec_connection">الاتصال</string>
<string name="settings_field_token">رمز المصادقة (Token)</string>
<string name="settings_keep_connected_in_background_title">البقاء متصلاً في الخلفية</string>
<string name="settings_keep_connected_in_background_desc">إبقاء هيرمس متصلاً أثناء استخدام تطبيقات أخرى. يعرض إشعاراً مستمراً وقد يستهلك المزيد من البطارية.</string>
<string name="settings_sec_behavior">السلوك</string>
<string name="settings_item_auto_reconnect">إعادة الاتصال التلقائي</string>
<string name="settings_desc_auto_reconnect">إعادة الاتصال تلقائيًا عند انقطاعه</string>
+6
View File
@@ -125,6 +125,10 @@
<!-- Notifications -->
<string name="notif_title">헤르메스</string>
<string name="notif_waiting_replies">헤르메스 응답 대기 중</string>
<string name="notif_connected_in_background">백그라운드에서 연결됨</string>
<string name="notif_connecting">연결 중…</string>
<string name="notif_waiting_network">네트워크 대기 중</string>
<string name="notif_reconnecting">재연결 중</string>
<string name="notif_new_message">새 메시지</string>
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string>
<string name="notif_reply_placeholder">답변 입력…</string>
@@ -180,6 +184,8 @@
<!-- Settings Screen -->
<string name="settings_sec_connection">연결</string>
<string name="settings_field_token">인증 토큰</string>
<string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string>
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
<string name="settings_sec_behavior">동작</string>
<string name="settings_item_auto_reconnect">자동 재연결</string>
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
+6
View File
@@ -128,6 +128,10 @@
<string name="screen_files">文件</string>
<string name="notif_title">Hermes</string>
<string name="notif_waiting_replies">等待 Hermes 回复</string>
<string name="notif_connected_in_background">在后台保持连接</string>
<string name="notif_connecting">正在连接…</string>
<string name="notif_waiting_network">等待网络连接</string>
<string name="notif_reconnecting">正在重新连接</string>
<string name="notif_new_message">新消息</string>
<string name="notif_clarification_needed">Hermes 需要确认</string>
<string name="notif_reply_placeholder">输入回复…</string>
@@ -177,6 +181,8 @@
<string name="chat_camera_error">拍照失败</string>
<string name="settings_sec_connection">连接</string>
<string name="settings_field_token">认证令牌</string>
<string name="settings_keep_connected_in_background_title">在后台保持连接</string>
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。</string>
<string name="settings_sec_behavior">行为</string>
<string name="settings_item_auto_reconnect">自动重连</string>
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
+6
View File
@@ -143,6 +143,10 @@
<!-- Notifications -->
<string name="notif_title">Hermes</string>
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
<string name="notif_connected_in_background">Connected in background</string>
<string name="notif_connecting">Connecting…</string>
<string name="notif_waiting_network">Waiting for network</string>
<string name="notif_reconnecting">Reconnecting</string>
<string name="notif_new_message">New message</string>
<string name="notif_clarification_needed">Hermes needs a clarification</string>
<string name="notif_input_needed">Hermes needs your input</string>
@@ -201,6 +205,8 @@
<!-- Settings Screen -->
<string name="settings_sec_connection">Connection</string>
<string name="settings_field_token">Auth Token</string>
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
<string name="settings_sec_behavior">Behavior</string>
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
@@ -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<ServerStoreState>("{}")
assertFalse(decoded.keepConnectedInBackground)
}
}
@@ -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() {}))
@@ -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<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.d(any<String>(), any<String>()) } 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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}