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.material3.Surface
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||||
@@ -20,9 +18,6 @@ import com.m57.hermescontrol.theme.HermesControlTheme
|
|||||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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.
|
* Apply the user-selected display language before any view is inflated.
|
||||||
* Reads the persisted code from [AuthManager]; an uninitialized store
|
* Reads the persisted code from [AuthManager]; an uninitialized store
|
||||||
@@ -38,8 +33,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Read notification tap sessionId (if any) from the intent that launched us
|
consumeNotificationIntent(intent)
|
||||||
notificationSessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
|
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
@@ -55,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
MainNavigation(sessionId = notificationSessionId)
|
MainNavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,10 +57,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// When a notification tap arrives while the app is already running
|
setIntent(intent)
|
||||||
// (task exists in background), Android delivers the intent here instead
|
consumeNotificationIntent(intent)
|
||||||
// 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)
|
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
|
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.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
|
||||||
@@ -16,7 +19,8 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
*/
|
*/
|
||||||
object NavigationController {
|
object NavigationController {
|
||||||
var backStack: NavBackStack<NavKey>? = null
|
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)
|
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
|
||||||
private val primaryScreens: MutableSet<NavKey> =
|
private val primaryScreens: MutableSet<NavKey> =
|
||||||
@@ -45,6 +49,14 @@ object NavigationController {
|
|||||||
stack.add(key)
|
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. */
|
/** Clear the stack and navigate to the given screen atomically. */
|
||||||
fun resetTo(screen: NavKey) {
|
fun resetTo(screen: NavKey) {
|
||||||
val stack = backStack ?: return
|
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.CleartextPolicy
|
||||||
import com.m57.hermescontrol.data.remote.CookieManager
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
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.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -310,6 +311,7 @@ object AuthManager {
|
|||||||
) {
|
) {
|
||||||
requirePrefs().edit().putString("token_$profileId", token).apply()
|
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||||
if (getSelectedProfileId() == profileId) {
|
if (getSelectedProfileId() == profileId) {
|
||||||
|
if (token == null) ActiveSessionHolder.clear()
|
||||||
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
|
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
|
||||||
// to prevent stale tokens during ticket refresh
|
// to prevent stale tokens during ticket refresh
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
@@ -326,6 +328,9 @@ object AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedProfileId(id: String?) {
|
fun setSelectedProfileId(id: String?) {
|
||||||
|
if (getSelectedProfileId() != id?.takeIf { it.isNotBlank() }) {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
}
|
||||||
serverStore.update { it.copy(selectedProfileId = id) }
|
serverStore.update { it.copy(selectedProfileId = id) }
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
tokenInitialized = false
|
tokenInitialized = false
|
||||||
|
|||||||
@@ -17,12 +17,32 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
*/
|
*/
|
||||||
object ActiveSessionHolder {
|
object ActiveSessionHolder {
|
||||||
private val _activeSessionId = MutableStateFlow<String?>(null)
|
private val _activeSessionId = MutableStateFlow<String?>(null)
|
||||||
|
private var activeStoredSessionId: String? = null
|
||||||
|
|
||||||
/** The currently active chat session id, or null if none is known yet. */
|
/** The currently active chat session id, or null if none is known yet. */
|
||||||
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
|
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
|
||||||
|
|
||||||
/** Update the active session id. Pass null to clear (e.g. on logout). */
|
/** 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
|
_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.DashboardSessionTokenRefresher
|
||||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -452,6 +453,7 @@ object HermesWsClient {
|
|||||||
|
|
||||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||||
fun disconnect(clearPendingMessages: Boolean = false) {
|
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
intentionalClose.set(true)
|
intentionalClose.set(true)
|
||||||
acceptQueuedMessages.set(!clearPendingMessages)
|
acceptQueuedMessages.set(!clearPendingMessages)
|
||||||
@@ -885,6 +887,7 @@ object HermesWsClient {
|
|||||||
// reason is still inspected internally to detect auth failures.
|
// reason is still inspected internally to detect auth failures.
|
||||||
Log.i(TAG, "WebSocket closed: $code")
|
Log.i(TAG, "WebSocket closed: $code")
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
stopHealthTracking()
|
stopHealthTracking()
|
||||||
if (code == 4001 || code == 4401 ||
|
if (code == 4001 || code == 4401 ||
|
||||||
reason.contains("unauthorized", ignoreCase = true) ||
|
reason.contains("unauthorized", ignoreCase = true) ||
|
||||||
@@ -915,6 +918,7 @@ object HermesWsClient {
|
|||||||
// detection.
|
// detection.
|
||||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
stopHealthTracking()
|
stopHealthTracking()
|
||||||
val code = response?.code ?: 0
|
val code = response?.code ?: 0
|
||||||
if (code == 401 || t.message?.contains(
|
if (code == 401 || t.message?.contains(
|
||||||
|
|||||||
@@ -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.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
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -98,7 +99,10 @@ class ChatNotificationService : Service() {
|
|||||||
.take(100)
|
.take(100)
|
||||||
.replace("\n", " ")
|
.replace("\n", " ")
|
||||||
.ifBlank { getString(R.string.notif_new_message) }
|
.ifBlank { getString(R.string.notif_new_message) }
|
||||||
showReplyNotification(preview, event.sessionId)
|
showReplyNotification(
|
||||||
|
preview,
|
||||||
|
ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
|
||||||
|
)
|
||||||
// The wait is over — retire the foreground
|
// The wait is over — retire the foreground
|
||||||
// service. The reply notification above
|
// service. The reply notification above
|
||||||
// replaces the persistent "waiting" one,
|
// replaces the persistent "waiting" one,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import android.content.Intent
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
import com.m57.hermescontrol.R
|
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.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,6 +22,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
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
|
// Reusable scope for async reply processing — avoids creating a new
|
||||||
@@ -74,7 +77,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
return@withContext
|
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 =
|
val entity =
|
||||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
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
|
// Mirror the active session id app-wide so session-scoped
|
||||||
// drawer screens (e.g. Processes, issue #532) can issue
|
// drawer screens (e.g. Processes, issue #532) can issue
|
||||||
// session-scoped RPCs. See ActiveSessionHolder.
|
// session-scoped RPCs. See ActiveSessionHolder.
|
||||||
ActiveSessionHolder.set(runtimeId)
|
ActiveSessionHolder.set(runtimeId, storageId)
|
||||||
_streamingState.update { StreamingState() }
|
_streamingState.update { StreamingState() }
|
||||||
addSystemMessage("Session created", persist = true)
|
addSystemMessage("Session created", persist = true)
|
||||||
loadSessions()
|
loadSessions()
|
||||||
@@ -868,7 +868,7 @@ class ChatViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
ActiveSessionHolder.set(runtimeId)
|
ActiveSessionHolder.set(runtimeId, storageId)
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
sessionGoneRecoveryInFlight = false
|
sessionGoneRecoveryInFlight = false
|
||||||
addSystemMessage("Session branched", persist = true)
|
addSystemMessage("Session branched", persist = true)
|
||||||
@@ -938,7 +938,7 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Mirror the active runtime session id app-wide (issue #532).
|
// Mirror the active runtime session id app-wide (issue #532).
|
||||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
|
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
||||||
addSystemMessage("Session resumed")
|
addSystemMessage("Session resumed")
|
||||||
fetchContextUsage()
|
fetchContextUsage()
|
||||||
val generation = request?.generation ?: sessionGeneration
|
val generation = request?.generation ?: sessionGeneration
|
||||||
@@ -1848,7 +1848,7 @@ class ChatViewModel(
|
|||||||
resumedGeneration = -1L
|
resumedGeneration = -1L
|
||||||
hydratedGeneration = -1L
|
hydratedGeneration = -1L
|
||||||
runtimeSessionId = null
|
runtimeSessionId = null
|
||||||
ActiveSessionHolder.set(sessionId)
|
ActiveSessionHolder.clear()
|
||||||
loadedMessageOffset = 0
|
loadedMessageOffset = 0
|
||||||
isSyncingMessages = false
|
isSyncingMessages = false
|
||||||
streamingController.resetStreaming()
|
streamingController.resetStreaming()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -48,20 +47,13 @@ fun ChatLifecycleEffects(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
// Publish the notification session ID to the ViewModel synchronously
|
|
||||||
SideEffect {
|
|
||||||
viewModel.initialSessionId = sessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to session from notification/history
|
// 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
|
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
||||||
val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId
|
val target = NavigationController.consumePendingSessionId() ?: sessionId
|
||||||
if (!target.isNullOrBlank()) {
|
if (!target.isNullOrBlank()) {
|
||||||
viewModel.switchSession(target)
|
viewModel.switchSession(target)
|
||||||
if (target == NavigationController.pendingSessionId) {
|
|
||||||
NavigationController.pendingSessionId = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -555,8 +555,7 @@ fun SessionsScreen(
|
|||||||
if (state.isSelecting) {
|
if (state.isSelecting) {
|
||||||
viewModel.toggleSessionSelection(session.id)
|
viewModel.toggleSessionSelection(session.id)
|
||||||
} else {
|
} else {
|
||||||
NavigationController.pendingSessionId = session.id
|
NavigationController.openChatSession(session.id)
|
||||||
NavigationController.navigateTo(ChatScreen)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCardLongClick = {
|
onCardLongClick = {
|
||||||
@@ -691,8 +690,7 @@ fun SessionsScreen(
|
|||||||
if (state.isSelecting) {
|
if (state.isSelecting) {
|
||||||
viewModel.toggleSessionSelection(session.id)
|
viewModel.toggleSessionSelection(session.id)
|
||||||
} else {
|
} else {
|
||||||
NavigationController.pendingSessionId = session.id
|
NavigationController.openChatSession(session.id)
|
||||||
NavigationController.navigateTo(ChatScreen)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCardLongClick = {
|
onCardLongClick = {
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ class NavigationControllerTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
// Start fresh — no pinned back stack from a previous test
|
// Start fresh — no pinned back stack from a previous test
|
||||||
NavigationController.backStack = null
|
NavigationController.backStack = null
|
||||||
|
NavigationController.consumePendingSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
NavigationController.backStack = null
|
NavigationController.backStack = null
|
||||||
|
NavigationController.consumePendingSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dedup guard: navigateTo with same key ──────────────────────────────
|
// ── Dedup guard: navigateTo with same key ──────────────────────────────
|
||||||
@@ -121,6 +123,29 @@ class NavigationControllerTest {
|
|||||||
assertEquals(KeysScreen, backStack.lastOrNull())
|
assertEquals(KeysScreen, backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openChatSession navigates once and exposes a consumable session id`() {
|
||||||
|
val backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||||
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
|
NavigationController.openChatSession("stored-session")
|
||||||
|
|
||||||
|
assertEquals(ChatScreen, backStack.lastOrNull())
|
||||||
|
assertEquals("stored-session", NavigationController.consumePendingSessionId())
|
||||||
|
assertNull(NavigationController.consumePendingSessionId())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openChatSession ignores blank ids`() {
|
||||||
|
val backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||||
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
|
NavigationController.openChatSession(" ")
|
||||||
|
|
||||||
|
assertEquals(HistoryScreen, backStack.lastOrNull())
|
||||||
|
assertNull(NavigationController.consumePendingSessionId())
|
||||||
|
}
|
||||||
|
|
||||||
// ── resetTo: atomic clear + navigate ──────────────────────────────────
|
// ── resetTo: atomic clear + navigate ──────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.m57.hermescontrol.data.session
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ActiveSessionHolderTest {
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runtime and stored ids resolve in both directions`() {
|
||||||
|
ActiveSessionHolder.set("runtime-session", "stored-session")
|
||||||
|
|
||||||
|
assertEquals("stored-session", ActiveSessionHolder.resolveStoredSessionId("runtime-session"))
|
||||||
|
assertEquals("runtime-session", ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session switch discards the previous mapping`() {
|
||||||
|
ActiveSessionHolder.set("runtime-previous", "stored-previous")
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
ActiveSessionHolder.set("runtime-current", "stored-current")
|
||||||
|
|
||||||
|
assertEquals("runtime-previous", ActiveSessionHolder.resolveStoredSessionId("runtime-previous"))
|
||||||
|
assertNull(ActiveSessionHolder.resolveRuntimeSessionId("stored-previous"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping can be rebuilt after reconnect`() {
|
||||||
|
ActiveSessionHolder.set("runtime-before", "stored-session")
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
|
||||||
|
assertNull(ActiveSessionHolder.activeSessionId.value)
|
||||||
|
assertNull(ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
|
||||||
|
|
||||||
|
ActiveSessionHolder.set("runtime-after", "stored-session")
|
||||||
|
assertEquals("runtime-after", ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
-3
@@ -9,7 +9,9 @@ import androidx.core.app.RemoteInput
|
|||||||
import com.m57.hermescontrol.data.local.ChatMessageDao
|
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||||
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
|
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.WsMethods
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -18,6 +20,7 @@ import io.mockk.mockkStatic
|
|||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -31,7 +34,7 @@ import org.junit.Test
|
|||||||
* handles inline reply actions on chat notifications.
|
* handles inline reply actions on chat notifications.
|
||||||
*
|
*
|
||||||
* Issue #291 (Critical Test Coverage): Verifies the full flow —
|
* Issue #291 (Critical Test Coverage): Verifies the full flow —
|
||||||
* (a) WS message is sent via [HermesWsClient.sendMessage]
|
* (a) WS message is confirmed via [HermesWsClient.request]
|
||||||
* (b) Reply is persisted to Room via [ChatMessageDao.upsert]
|
* (b) Reply is persisted to Room via [ChatMessageDao.upsert]
|
||||||
* (c) A "Replied" confirmation notification is posted
|
* (c) A "Replied" confirmation notification is posted
|
||||||
* (d) Invalid/missing input is handled gracefully (no crash)
|
* (d) Invalid/missing input is handled gracefully (no crash)
|
||||||
@@ -59,6 +62,7 @@ class NotificationReplyReceiverTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
upsertCallCount = 0
|
upsertCallCount = 0
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
|
||||||
// Mock Android framework statics (same pattern as HermesWsClientTest)
|
// Mock Android framework statics (same pattern as HermesWsClientTest)
|
||||||
mockkStatic(android.util.Log::class)
|
mockkStatic(android.util.Log::class)
|
||||||
@@ -97,7 +101,17 @@ class NotificationReplyReceiverTest {
|
|||||||
|
|
||||||
// Mock HermesWsClient singleton
|
// Mock HermesWsClient singleton
|
||||||
mockkObject(HermesWsClient)
|
mockkObject(HermesWsClient)
|
||||||
every { HermesWsClient.sendMessage(any(), any()) } returns "mock-req-id"
|
every { HermesWsClient.request(any(), any(), any()) } answers {
|
||||||
|
val method = arg<String>(0)
|
||||||
|
val params = arg<Map<String, Any>>(1)
|
||||||
|
val result =
|
||||||
|
if (method == WsMethods.SESSION_RESUME) {
|
||||||
|
mapOf("session_id" to "runtime-${params["session_id"]}")
|
||||||
|
} else {
|
||||||
|
mapOf("accepted" to true)
|
||||||
|
}
|
||||||
|
CompletableDeferred<Any?>(result)
|
||||||
|
}
|
||||||
|
|
||||||
// Create receiver with overridden goAsyncCompat() and buildReplyNotification()
|
// Create receiver with overridden goAsyncCompat() and buildReplyNotification()
|
||||||
// — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk,
|
// — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk,
|
||||||
@@ -113,6 +127,7 @@ class NotificationReplyReceiverTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
HermesDatabase.setForTest(null)
|
HermesDatabase.setForTest(null)
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
@@ -126,7 +141,13 @@ class NotificationReplyReceiverTest {
|
|||||||
receiver.onReceive(mockContext, mockIntent)
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
Thread.sleep(500) // Need to wait for coroutine
|
Thread.sleep(500) // Need to wait for coroutine
|
||||||
|
|
||||||
verify { HermesWsClient.sendMessage("session-abc", "Hello") }
|
verify {
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -275,6 +296,88 @@ class NotificationReplyReceiverTest {
|
|||||||
verify { mockPendingResult.finish() }
|
verify { mockPendingResult.finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reply after reconnect resumes stored id and never sends stale runtime id`() {
|
||||||
|
ActiveSessionHolder.set("stale-runtime", "session-abc")
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
givenValidReply("session-abc", "Hello")
|
||||||
|
|
||||||
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
|
Thread.sleep(500)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.request(WsMethods.SESSION_RESUME, match { it["session_id"] == "session-abc" }, any())
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
match { it["session_id"] == "runtime-session-abc" },
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verify(inverse = true) {
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
match { it["session_id"] == "stale-runtime" },
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reply is persisted and confirmed only after send succeeds`() {
|
||||||
|
val sendResult = CompletableDeferred<Any?>()
|
||||||
|
every { HermesWsClient.request(WsMethods.PROMPT_SUBMIT, any(), any()) } returns sendResult
|
||||||
|
givenValidReply("session-abc", "Hello")
|
||||||
|
|
||||||
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
|
Thread.sleep(100)
|
||||||
|
|
||||||
|
assertEquals(0, upsertCallCount)
|
||||||
|
verify(inverse = true) { mockNotificationManager.notify(any(), any()) }
|
||||||
|
|
||||||
|
sendResult.complete(mapOf("accepted" to true))
|
||||||
|
Thread.sleep(500)
|
||||||
|
|
||||||
|
assertEquals(1, upsertCallCount)
|
||||||
|
verify { mockNotificationManager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed send is neither persisted nor confirmed`() {
|
||||||
|
every { HermesWsClient.request(WsMethods.PROMPT_SUBMIT, any(), any()) } returns
|
||||||
|
CompletableDeferred<Any?>().apply { completeExceptionally(IllegalStateException("rejected")) }
|
||||||
|
givenValidReply("session-abc", "Hello")
|
||||||
|
|
||||||
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
|
Thread.sleep(500)
|
||||||
|
|
||||||
|
assertEquals(0, upsertCallCount)
|
||||||
|
verify(inverse = true) { mockNotificationManager.notify(any(), any()) }
|
||||||
|
verify { mockPendingResult.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed resume remains retryable on the next reply`() {
|
||||||
|
var resumeAttempts = 0
|
||||||
|
every { HermesWsClient.request(WsMethods.SESSION_RESUME, any(), any()) } answers {
|
||||||
|
resumeAttempts++
|
||||||
|
if (resumeAttempts == 1) {
|
||||||
|
CompletableDeferred<Any?>().apply { completeExceptionally(IllegalStateException("rejected")) }
|
||||||
|
} else {
|
||||||
|
CompletableDeferred<Any?>(mapOf("session_id" to "runtime-session-abc"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
givenValidReply("session-abc", "Hello")
|
||||||
|
|
||||||
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
|
Thread.sleep(500)
|
||||||
|
receiver.onReceive(mockContext, mockIntent)
|
||||||
|
Thread.sleep(500)
|
||||||
|
|
||||||
|
assertEquals(2, resumeAttempts)
|
||||||
|
assertEquals(1, upsertCallCount)
|
||||||
|
assertEquals("runtime-session-abc", ActiveSessionHolder.resolveRuntimeSessionId("session-abc"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Room hang triggers timeout and finishes pending result`() {
|
fun `Room hang triggers timeout and finishes pending result`() {
|
||||||
givenValidReply("session-abc", "Hello")
|
givenValidReply("session-abc", "Hello")
|
||||||
|
|||||||
Reference in new issue
Block a user