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 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()
|
||||
|
||||
+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.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,
|
||||
|
||||
Reference in new issue
Block a user