feat: add setting to keep connection alive in background (#1196)
This commit is contained in:
19 files changed
+1155
-24
No files matched your search
@@ -29,6 +29,7 @@ data class ServerStoreState(
|
|||||||
val showAssistantMessageTokens: Boolean = true,
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
val showTokensPerSecond: Boolean = true,
|
val showTokensPerSecond: Boolean = true,
|
||||||
val showModelProvider: Boolean = false,
|
val showModelProvider: Boolean = false,
|
||||||
|
val keepConnectedInBackground: Boolean = false,
|
||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -856,6 +856,12 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(showModelProvider = shown) }
|
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) ───────────────────────────────────
|
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||||
|
|
||||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ object HermesWsClient {
|
|||||||
private val acceptQueuedMessages = AtomicBoolean(true)
|
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||||
private val appInForeground = AtomicBoolean(true)
|
private val appInForeground = AtomicBoolean(true)
|
||||||
private val externalActivityConnectionLease = AtomicBoolean(false)
|
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||||
|
private val backgroundConnectionLease = AtomicBoolean(false)
|
||||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||||
private val outboundLock = Any()
|
private val outboundLock = Any()
|
||||||
@@ -433,10 +434,23 @@ object HermesWsClient {
|
|||||||
disconnectIfIdleInBackground()
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun acquireBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
|
||||||
|
|
||||||
private fun disconnectIfIdleInBackground() {
|
private fun disconnectIfIdleInBackground() {
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
if (!appInForeground.get() &&
|
if (!appInForeground.get() &&
|
||||||
!externalActivityConnectionLease.get() &&
|
!externalActivityConnectionLease.get() &&
|
||||||
|
!backgroundConnectionLease.get() &&
|
||||||
!pendingReply &&
|
!pendingReply &&
|
||||||
pendingCalls.isEmpty() &&
|
pendingCalls.isEmpty() &&
|
||||||
messageQueue.isEmpty()
|
messageQueue.isEmpty()
|
||||||
@@ -708,6 +722,7 @@ object HermesWsClient {
|
|||||||
webSocket = null
|
webSocket = null
|
||||||
closingSocket = null
|
closingSocket = null
|
||||||
if (clearPendingMessages) {
|
if (clearPendingMessages) {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
messageQueue.clear()
|
messageQueue.clear()
|
||||||
queuedMessagesById.clear()
|
queuedMessagesById.clear()
|
||||||
pendingPromptSubmits.clear()
|
pendingPromptSubmits.clear()
|
||||||
|
|||||||
+115
@@ -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.MainActivity
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
@@ -43,16 +44,16 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* message notifications, which is correct.
|
* message notifications, which is correct.
|
||||||
*
|
*
|
||||||
* Lifecycle:
|
* Lifecycle:
|
||||||
* - Started by [NotificationHelper.start] when the app goes to the
|
* - Started by [NotificationHelper.start] when the app goes to the background
|
||||||
* background while a reply is still pending (the user sent a message or
|
* either while a reply is still pending (issue #794) or when persistent background
|
||||||
* replied from a notification and the agent has not finished yet).
|
* connection ([com.m57.hermescontrol.data.local.AuthManager.isKeepConnectedInBackground])
|
||||||
* [NotificationHelper.start] is a no-op when nothing is pending, so the
|
* is enabled.
|
||||||
* service — and its mandatory persistent notification — only exists
|
* - In persistent keep-connected mode, the service stays alive across idle states
|
||||||
* while the user is actually waiting for a reply (issue #794).
|
* and updates its ongoing notification truthfully ([BackgroundNotificationState]).
|
||||||
* - Stopped by [NotificationHelper.stop] when the app returns to the
|
* - In replies-only mode (default), the service retires itself once the pending reply
|
||||||
* foreground (called from MainActivity.onStart), or by the
|
* completes in the background.
|
||||||
* service itself once the pending reply completes in the background
|
* - Stopped by [NotificationHelper.stop] when the app returns to the foreground, or
|
||||||
* (the reply notification replaces the persistent "waiting" one).
|
* retired automatically upon auth expiry or terminal disconnection.
|
||||||
*
|
*
|
||||||
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
|
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
|
||||||
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
|
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
|
||||||
@@ -68,20 +69,30 @@ class ChatNotificationService : Service() {
|
|||||||
private val isAppInForeground = AtomicBoolean(false)
|
private val isAppInForeground = AtomicBoolean(false)
|
||||||
internal val lifecycle = ForegroundServiceLifecycle()
|
internal val lifecycle = ForegroundServiceLifecycle()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var activeServiceInstance: ChatNotificationService? = null
|
||||||
|
|
||||||
fun setAppForeground(foreground: Boolean) {
|
fun setAppForeground(foreground: Boolean) {
|
||||||
isAppInForeground.set(foreground)
|
isAppInForeground.set(foreground)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAppInForeground(): Boolean = isAppInForeground.get()
|
fun isAppInForeground(): Boolean = isAppInForeground.get()
|
||||||
|
|
||||||
|
fun updateForegroundNotification(state: BackgroundNotificationState) {
|
||||||
|
activeServiceInstance?.updateNotification(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private var eventCollector: Job? = null
|
private var eventCollector: Job? = null
|
||||||
|
private var statusCollector: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
activeServiceInstance = this
|
||||||
createNotificationChannels()
|
createNotificationChannels()
|
||||||
startEventCollection()
|
startEventCollection()
|
||||||
|
startStatusCollection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startEventCollection() {
|
private fun startEventCollection() {
|
||||||
@@ -113,7 +124,7 @@ class ChatNotificationService : Service() {
|
|||||||
// service is not restarted on the next
|
// service is not restarted on the next
|
||||||
// ON_STOP (issue #794).
|
// ON_STOP (issue #794).
|
||||||
// A delayed completion must not retire a newer turn/start.
|
// A delayed completion must not retire a newer turn/start.
|
||||||
if (!HermesWsClient.pendingReply) lifecycle.complete(generation)
|
BackgroundConnectionController.default.onReplyCompleted(generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.ClarifyRequest -> {
|
is WsEvent.ClarifyRequest -> {
|
||||||
@@ -269,10 +280,13 @@ class ChatNotificationService : Service() {
|
|||||||
flags: Int,
|
flags: Int,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): Int {
|
): Int {
|
||||||
|
val initialDecision =
|
||||||
|
BackgroundConnectionPolicy.evaluate(BackgroundConnectionController.defaultSnapshot(isDeparting = true))
|
||||||
|
val initialText = resolveNotificationText(initialDecision.notificationState)
|
||||||
lifecycle.onStart(
|
lifecycle.onStart(
|
||||||
owner = this,
|
owner = this,
|
||||||
promote = {
|
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:
|
// Do not remove foreground status before Android accepts retirement:
|
||||||
// a newer start may already be queued, but not delivered to us yet.
|
// 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 onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
if (activeServiceInstance === this) {
|
||||||
|
activeServiceInstance = null
|
||||||
|
}
|
||||||
lifecycle.onDestroyed(this)
|
lifecycle.onDestroyed(this)
|
||||||
eventCollector?.cancel()
|
eventCollector?.cancel()
|
||||||
|
statusCollector?.cancel()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
super.onDestroy()
|
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 {
|
object NotificationHelper {
|
||||||
fun start(context: Context) {
|
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)
|
val intent = Intent(context, ChatNotificationService::class.java)
|
||||||
ChatNotificationService.lifecycle.start {
|
BackgroundConnectionController.default.onAppPause {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.startForegroundService(intent)
|
context.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
@@ -314,9 +362,7 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
// Rotation can resume the activity before the queued service is created.
|
BackgroundConnectionController.default.onAppResume()
|
||||||
// Direct stopService here crashes Android while foreground promotion is owed.
|
|
||||||
ChatNotificationService.lifecycle.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAppForeground(
|
fun setAppForeground(
|
||||||
@@ -325,6 +371,9 @@ object NotificationHelper {
|
|||||||
) {
|
) {
|
||||||
ChatNotificationService.setAppForeground(foreground)
|
ChatNotificationService.setAppForeground(foreground)
|
||||||
HermesWsClient.setAppForeground(foreground)
|
HermesWsClient.setAppForeground(foreground)
|
||||||
|
if (!foreground) {
|
||||||
|
BackgroundConnectionController.default.reconcileState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
|
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ fun SettingsScreen(
|
|||||||
icon = Icons.Filled.Tune,
|
icon = Icons.Filled.Tune,
|
||||||
label = stringResource(R.string.settings_sec_behavior),
|
label = stringResource(R.string.settings_sec_behavior),
|
||||||
summary =
|
summary =
|
||||||
if (state.autoReconnect || state.restoreLastSession) {
|
if (state.autoReconnect || state.restoreLastSession ||
|
||||||
|
state.keepConnectedInBackground
|
||||||
|
) {
|
||||||
stringResource(R.string.settings_summary_on)
|
stringResource(R.string.settings_summary_on)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_summary_off)
|
stringResource(R.string.settings_summary_off)
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ internal fun SettingsBehaviorPage(
|
|||||||
BehaviorSection(
|
BehaviorSection(
|
||||||
autoReconnect = state.autoReconnect,
|
autoReconnect = state.autoReconnect,
|
||||||
onAutoReconnectChange = viewModel::onAutoReconnectChange,
|
onAutoReconnectChange = viewModel::onAutoReconnectChange,
|
||||||
|
keepConnectedInBackground = state.keepConnectedInBackground,
|
||||||
|
onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange,
|
||||||
restoreLastSession = state.restoreLastSession,
|
restoreLastSession = state.restoreLastSession,
|
||||||
onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange,
|
onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ data class SettingsUiState(
|
|||||||
val showAssistantMessageTokens: Boolean = true,
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
val showTokensPerSecond: Boolean = true,
|
val showTokensPerSecond: Boolean = true,
|
||||||
val showModelProvider: Boolean = false,
|
val showModelProvider: Boolean = false,
|
||||||
|
val keepConnectedInBackground: Boolean = false,
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -91,6 +92,7 @@ class SettingsViewModel(
|
|||||||
val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled()
|
val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled()
|
||||||
val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled()
|
val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled()
|
||||||
val showModelProvider = AuthManager.isModelProviderShown()
|
val showModelProvider = AuthManager.isModelProviderShown()
|
||||||
|
val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground()
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -117,6 +119,7 @@ class SettingsViewModel(
|
|||||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
showTokensPerSecond = showTokensPerSecond,
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
showModelProvider = showModelProvider,
|
showModelProvider = showModelProvider,
|
||||||
|
keepConnectedInBackground = keepConnectedInBackground,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -368,6 +371,11 @@ class SettingsViewModel(
|
|||||||
AuthManager.setModelProviderShown(enabled)
|
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. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
@@ -402,6 +410,7 @@ class SettingsViewModel(
|
|||||||
AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens)
|
AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens)
|
||||||
AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond)
|
AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond)
|
||||||
AuthManager.setModelProviderShown(state.showModelProvider)
|
AuthManager.setModelProviderShown(state.showModelProvider)
|
||||||
|
AuthManager.setKeepConnectedInBackground(state.keepConnectedInBackground)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.m57.hermescontrol.ui.settings.SectionCard
|
|||||||
internal fun BehaviorSection(
|
internal fun BehaviorSection(
|
||||||
autoReconnect: Boolean,
|
autoReconnect: Boolean,
|
||||||
onAutoReconnectChange: (Boolean) -> Unit,
|
onAutoReconnectChange: (Boolean) -> Unit,
|
||||||
|
keepConnectedInBackground: Boolean,
|
||||||
|
onKeepConnectedInBackgroundChange: (Boolean) -> Unit,
|
||||||
restoreLastSession: Boolean,
|
restoreLastSession: Boolean,
|
||||||
onRestoreLastSessionChange: (Boolean) -> Unit,
|
onRestoreLastSessionChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -60,6 +62,39 @@ internal fun BehaviorSection(
|
|||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|||||||
@@ -128,6 +128,10 @@
|
|||||||
<string name="screen_files">الملفات</string>
|
<string name="screen_files">الملفات</string>
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">Hermes</string>
|
||||||
<string name="notif_waiting_replies">بانتظار ردود 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_new_message">رسالة جديدة</string>
|
||||||
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string>
|
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string>
|
||||||
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
|
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
|
||||||
@@ -177,6 +181,8 @@
|
|||||||
<string name="chat_camera_error">تعذر التقاط الصورة</string>
|
<string name="chat_camera_error">تعذر التقاط الصورة</string>
|
||||||
<string name="settings_sec_connection">الاتصال</string>
|
<string name="settings_sec_connection">الاتصال</string>
|
||||||
<string name="settings_field_token">رمز المصادقة (Token)</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_sec_behavior">السلوك</string>
|
||||||
<string name="settings_item_auto_reconnect">إعادة الاتصال التلقائي</string>
|
<string name="settings_item_auto_reconnect">إعادة الاتصال التلقائي</string>
|
||||||
<string name="settings_desc_auto_reconnect">إعادة الاتصال تلقائيًا عند انقطاعه</string>
|
<string name="settings_desc_auto_reconnect">إعادة الاتصال تلقائيًا عند انقطاعه</string>
|
||||||
|
|||||||
@@ -125,6 +125,10 @@
|
|||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">헤르메스</string>
|
<string name="notif_title">헤르메스</string>
|
||||||
<string name="notif_waiting_replies">헤르메스 응답 대기 중</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_new_message">새 메시지</string>
|
||||||
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string>
|
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string>
|
||||||
<string name="notif_reply_placeholder">답변 입력…</string>
|
<string name="notif_reply_placeholder">답변 입력…</string>
|
||||||
@@ -180,6 +184,8 @@
|
|||||||
<!-- Settings Screen -->
|
<!-- Settings Screen -->
|
||||||
<string name="settings_sec_connection">연결</string>
|
<string name="settings_sec_connection">연결</string>
|
||||||
<string name="settings_field_token">인증 토큰</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_sec_behavior">동작</string>
|
||||||
<string name="settings_item_auto_reconnect">자동 재연결</string>
|
<string name="settings_item_auto_reconnect">자동 재연결</string>
|
||||||
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
||||||
|
|||||||
@@ -128,6 +128,10 @@
|
|||||||
<string name="screen_files">文件</string>
|
<string name="screen_files">文件</string>
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">Hermes</string>
|
||||||
<string name="notif_waiting_replies">等待 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_new_message">新消息</string>
|
||||||
<string name="notif_clarification_needed">Hermes 需要确认</string>
|
<string name="notif_clarification_needed">Hermes 需要确认</string>
|
||||||
<string name="notif_reply_placeholder">输入回复…</string>
|
<string name="notif_reply_placeholder">输入回复…</string>
|
||||||
@@ -177,6 +181,8 @@
|
|||||||
<string name="chat_camera_error">拍照失败</string>
|
<string name="chat_camera_error">拍照失败</string>
|
||||||
<string name="settings_sec_connection">连接</string>
|
<string name="settings_sec_connection">连接</string>
|
||||||
<string name="settings_field_token">认证令牌</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_sec_behavior">行为</string>
|
||||||
<string name="settings_item_auto_reconnect">自动重连</string>
|
<string name="settings_item_auto_reconnect">自动重连</string>
|
||||||
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
||||||
|
|||||||
@@ -143,6 +143,10 @@
|
|||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">Hermes</string>
|
||||||
<string name="notif_waiting_replies">Waiting for Hermes replies</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_new_message">New message</string>
|
||||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
||||||
<string name="notif_input_needed">Hermes needs your input</string>
|
<string name="notif_input_needed">Hermes needs your input</string>
|
||||||
@@ -201,6 +205,8 @@
|
|||||||
<!-- Settings Screen -->
|
<!-- Settings Screen -->
|
||||||
<string name="settings_sec_connection">Connection</string>
|
<string name="settings_sec_connection">Connection</string>
|
||||||
<string name="settings_field_token">Auth Token</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_sec_behavior">Behavior</string>
|
||||||
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
|
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
|
||||||
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ServerStoreTest {
|
|||||||
assertEquals("token", state.wsAuthParam)
|
assertEquals("token", state.wsAuthParam)
|
||||||
assertEquals(1.0f, state.chatFontScale)
|
assertEquals(1.0f, state.chatFontScale)
|
||||||
assertFalse(state.showModelProvider)
|
assertFalse(state.showModelProvider)
|
||||||
|
assertFalse(state.keepConnectedInBackground)
|
||||||
assertTrue(state.connectionProfiles.isEmpty())
|
assertTrue(state.connectionProfiles.isEmpty())
|
||||||
assertNull(state.selectedProfileId)
|
assertNull(state.selectedProfileId)
|
||||||
}
|
}
|
||||||
@@ -77,4 +78,19 @@ class ServerStoreTest {
|
|||||||
val updated = state.copy(showModelProvider = true)
|
val updated = state.copy(showModelProvider = true)
|
||||||
assertTrue(updated.showModelProvider)
|
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.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
HermesWsClient.setAppForeground(true)
|
HermesWsClient.setAppForeground(true)
|
||||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||||
acceptQueuedMessagesField.isAccessible = true
|
acceptQueuedMessagesField.isAccessible = true
|
||||||
@@ -115,6 +116,7 @@ class HermesWsClientTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
// 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
|
// 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)
|
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
|
@Test
|
||||||
fun testBackgroundWithPendingReplyStaysConnected() {
|
fun testBackgroundWithPendingReplyStaysConnected() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
|
|||||||
+410
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -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.isAssistantMessageTokensEnabled() } returns true
|
||||||
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||||
every { AuthManager.isModelProviderShown() } returns false
|
every { AuthManager.isModelProviderShown() } returns false
|
||||||
|
every { AuthManager.isKeepConnectedInBackground() } returns false
|
||||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||||
@@ -95,6 +96,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit
|
every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit
|
every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setModelProviderShown(any()) } returns Unit
|
every { AuthManager.setModelProviderShown(any()) } returns Unit
|
||||||
|
every { AuthManager.setKeepConnectedInBackground(any()) } returns Unit
|
||||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||||
storedSelectedProfileId = firstArg()
|
storedSelectedProfileId = firstArg()
|
||||||
}
|
}
|
||||||
@@ -362,4 +364,36 @@ class SettingsViewModelTest {
|
|||||||
verify { AuthManager.setModelProviderShown(true) }
|
verify { AuthManager.setModelProviderShown(true) }
|
||||||
assertEquals(true, viewModel.uiState.value.isSaved)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user