fix(notification): resume stored session before reply (#805)
This commit is contained in:
1 parent
e1a04a9396
commit
356ccc98b5
13 files changed
+268
-39
No files matched your search
@@ -11,8 +11,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
@@ -20,9 +18,6 @@ import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
// Observable state so both onCreate and onNewIntent updates flow to Compose
|
||||
private var notificationSessionId by mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Apply the user-selected display language before any view is inflated.
|
||||
* Reads the persisted code from [AuthManager]; an uninitialized store
|
||||
@@ -38,8 +33,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Read notification tap sessionId (if any) from the intent that launched us
|
||||
notificationSessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
|
||||
consumeNotificationIntent(intent)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
@@ -55,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MainNavigation(sessionId = notificationSessionId)
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +57,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// When a notification tap arrives while the app is already running
|
||||
// (task exists in background), Android delivers the intent here instead
|
||||
// of onCreate. Update the observable state so Compose recomposes
|
||||
// and ChatScreen's LaunchedEffect switches to the correct session.
|
||||
notificationSessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
|
||||
setIntent(intent)
|
||||
consumeNotificationIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeNotificationIntent(intent: Intent?) {
|
||||
val sessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
|
||||
intent?.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
|
||||
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
@@ -16,7 +19,8 @@ import androidx.navigation3.runtime.NavKey
|
||||
*/
|
||||
object NavigationController {
|
||||
var backStack: NavBackStack<NavKey>? = null
|
||||
var pendingSessionId: String? = null
|
||||
var pendingSessionId: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
|
||||
private val primaryScreens: MutableSet<NavKey> =
|
||||
@@ -45,6 +49,14 @@ object NavigationController {
|
||||
stack.add(key)
|
||||
}
|
||||
|
||||
fun openChatSession(sessionId: String) {
|
||||
if (sessionId.isBlank()) return
|
||||
pendingSessionId = sessionId
|
||||
navigateTo(ChatScreen)
|
||||
}
|
||||
|
||||
fun consumePendingSessionId(): String? = pendingSessionId.also { pendingSessionId = null }
|
||||
|
||||
/** Clear the stack and navigate to the given screen atomically. */
|
||||
fun resetTo(screen: NavKey) {
|
||||
val stack = backStack ?: return
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -310,6 +311,7 @@ object AuthManager {
|
||||
) {
|
||||
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||
if (getSelectedProfileId() == profileId) {
|
||||
if (token == null) ActiveSessionHolder.clear()
|
||||
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
|
||||
// to prevent stale tokens during ticket refresh
|
||||
synchronized(this) {
|
||||
@@ -326,6 +328,9 @@ object AuthManager {
|
||||
}
|
||||
|
||||
fun setSelectedProfileId(id: String?) {
|
||||
if (getSelectedProfileId() != id?.takeIf { it.isNotBlank() }) {
|
||||
ActiveSessionHolder.clear()
|
||||
}
|
||||
serverStore.update { it.copy(selectedProfileId = id) }
|
||||
synchronized(this) {
|
||||
tokenInitialized = false
|
||||
|
||||
@@ -17,12 +17,32 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
*/
|
||||
object ActiveSessionHolder {
|
||||
private val _activeSessionId = MutableStateFlow<String?>(null)
|
||||
private var activeStoredSessionId: String? = null
|
||||
|
||||
/** The currently active chat session id, or null if none is known yet. */
|
||||
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
|
||||
|
||||
/** Update the active session id. Pass null to clear (e.g. on logout). */
|
||||
fun set(sessionId: String?) {
|
||||
@Synchronized
|
||||
fun set(
|
||||
sessionId: String?,
|
||||
storedSessionId: String? = null,
|
||||
) {
|
||||
_activeSessionId.value = sessionId
|
||||
activeStoredSessionId = storedSessionId
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resolveStoredSessionId(sessionId: String?): String? =
|
||||
if (sessionId == _activeSessionId.value) activeStoredSessionId ?: sessionId else sessionId
|
||||
|
||||
@Synchronized
|
||||
fun resolveRuntimeSessionId(sessionId: String): String? =
|
||||
_activeSessionId.value.takeIf { activeStoredSessionId == sessionId }
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
_activeSessionId.value = null
|
||||
activeStoredSessionId = null
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -452,6 +453,7 @@ object HermesWsClient {
|
||||
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||
ActiveSessionHolder.clear()
|
||||
synchronized(outboundLock) {
|
||||
intentionalClose.set(true)
|
||||
acceptQueuedMessages.set(!clearPendingMessages)
|
||||
@@ -885,6 +887,7 @@ object HermesWsClient {
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
@@ -915,6 +918,7 @@ object HermesWsClient {
|
||||
// detection.
|
||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || t.message?.contains(
|
||||
|
||||
@@ -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.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -98,7 +99,10 @@ class ChatNotificationService : Service() {
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { getString(R.string.notif_new_message) }
|
||||
showReplyNotification(preview, event.sessionId)
|
||||
showReplyNotification(
|
||||
preview,
|
||||
ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
|
||||
)
|
||||
// The wait is over — retire the foreground
|
||||
// service. The reply notification above
|
||||
// replaces the persistent "waiting" one,
|
||||
|
||||
@@ -8,7 +8,9 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -20,6 +22,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
||||
private const val REPLY_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
|
||||
// Reusable scope for async reply processing — avoids creating a new
|
||||
@@ -74,7 +77,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
HermesWsClient.sendMessage(sessionId, replyText)
|
||||
val runtimeSessionId =
|
||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||
?: resumeSession(sessionId)
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await()
|
||||
|
||||
val entity =
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
@@ -111,4 +122,19 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resumeSession(storedSessionId: String): String {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to storedSessionId),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await() as? Map<*, *>
|
||||
val runtimeSessionId =
|
||||
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
?: error("Resume returned no runtime session id")
|
||||
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
||||
return runtimeSessionId
|
||||
}
|
||||
}
|
||||
@@ -845,7 +845,7 @@ class ChatViewModel(
|
||||
// Mirror the active session id app-wide so session-scoped
|
||||
// drawer screens (e.g. Processes, issue #532) can issue
|
||||
// session-scoped RPCs. See ActiveSessionHolder.
|
||||
ActiveSessionHolder.set(runtimeId)
|
||||
ActiveSessionHolder.set(runtimeId, storageId)
|
||||
_streamingState.update { StreamingState() }
|
||||
addSystemMessage("Session created", persist = true)
|
||||
loadSessions()
|
||||
@@ -868,7 +868,7 @@ class ChatViewModel(
|
||||
isLoading = false,
|
||||
)
|
||||
runtimeSessionId = runtimeId
|
||||
ActiveSessionHolder.set(runtimeId)
|
||||
ActiveSessionHolder.set(runtimeId, storageId)
|
||||
sessionHasServerPresence = false
|
||||
sessionGoneRecoveryInFlight = false
|
||||
addSystemMessage("Session branched", persist = true)
|
||||
@@ -938,7 +938,7 @@ class ChatViewModel(
|
||||
)
|
||||
}
|
||||
// Mirror the active runtime session id app-wide (issue #532).
|
||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
|
||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
||||
addSystemMessage("Session resumed")
|
||||
fetchContextUsage()
|
||||
val generation = request?.generation ?: sessionGeneration
|
||||
@@ -1848,7 +1848,7 @@ class ChatViewModel(
|
||||
resumedGeneration = -1L
|
||||
hydratedGeneration = -1L
|
||||
runtimeSessionId = null
|
||||
ActiveSessionHolder.set(sessionId)
|
||||
ActiveSessionHolder.clear()
|
||||
loadedMessageOffset = 0
|
||||
isSyncingMessages = false
|
||||
streamingController.resetStreaming()
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -48,20 +47,13 @@ fun ChatLifecycleEffects(
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Publish the notification session ID to the ViewModel synchronously
|
||||
SideEffect {
|
||||
viewModel.initialSessionId = sessionId
|
||||
}
|
||||
|
||||
// Switch to session from notification/history
|
||||
LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) {
|
||||
val pendingSessionId = NavigationController.pendingSessionId
|
||||
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
|
||||
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
||||
val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId
|
||||
val target = NavigationController.consumePendingSessionId() ?: sessionId
|
||||
if (!target.isNullOrBlank()) {
|
||||
viewModel.switchSession(target)
|
||||
if (target == NavigationController.pendingSessionId) {
|
||||
NavigationController.pendingSessionId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -555,8 +555,7 @@ fun SessionsScreen(
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.pendingSessionId = session.id
|
||||
NavigationController.navigateTo(ChatScreen)
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onCardLongClick = {
|
||||
@@ -691,8 +690,7 @@ fun SessionsScreen(
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.pendingSessionId = session.id
|
||||
NavigationController.navigateTo(ChatScreen)
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onCardLongClick = {
|
||||
|
||||
Reference in new issue
Block a user