diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 8ec9ed90..be85a28c 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -215,14 +215,22 @@ fun MainNavigation(sessionId: String? = null) { var lastBackPressTime by remember { mutableLongStateOf(0L) } val backToastText = stringResource(R.string.press_back_again_to_exit) - // Double-back to exit on root ChatScreen + // Double-back to exit on root ChatScreen — unless the chat was opened from + // a list (History, Bots): then the first back returns to that list (Telegram + // chat-list parity) and only a further double-back exits. BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) { - val now = System.currentTimeMillis() - if (now - lastBackPressTime < 2000L) { - (context as? Activity)?.finish() + val pendingReturn = NavigationController.chatReturnScreen + if (pendingReturn != null) { + NavigationController.consumeChatReturnScreen() + NavigationController.navigateTo(pendingReturn) } else { - lastBackPressTime = now - Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + val now = System.currentTimeMillis() + if (now - lastBackPressTime < 2000L) { + (context as? Activity)?.finish() + } else { + lastBackPressTime = now + Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 466067a2..36c69835 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -33,6 +33,16 @@ object NavigationController { private set var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null) private set + + /** + * The list screen a chat was opened from (History, Bots). Back out of that + * chat returns here instead of exiting the app (Telegram chat-list parity). + * Consumed by MainNavigation's root-chat back handler; cleared by any other + * navigation so a stale target can never hijack a later back press. + */ + var chatReturnScreen: NavKey? by mutableStateOf(null) + private set + val pendingSessionId: String? get() = pendingChatNavigation?.sessionId private var nextChatNavigationRequestId = 0L @@ -45,8 +55,18 @@ object NavigationController { */ fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key } + /** Returns the armed return screen and disarms it. */ + fun consumeChatReturnScreen(): NavKey? = chatReturnScreen.also { chatReturnScreen = null } + fun navigateTo(key: NavKey) { val stack = backStack ?: return + + // Any explicit navigation invalidates the pending chat return — only a + // session/chat opened from a list arms it again (see [queueChatNavigation]). + // This must precede the dedup guard: re-selecting Chat in the drawer is + // still an explicit navigation and must not keep a stale return armed. + chatReturnScreen = null + if (stack.lastOrNull() == key) return if (key == ChatScreen) { @@ -73,11 +93,16 @@ object NavigationController { } fun openChatSession(sessionId: String) { - queueChatNavigation(sessionId, scrollToBottom = false) + // Opened from a list (History cards, search results, Bots): arm the + // return so back from the chat lands back on that list instead of + // exiting the app (Telegram chat-list parity). + queueChatNavigation(sessionId, scrollToBottom = false, returnToList = true) } fun openChatSessionFromNotification(sessionId: String) { - queueChatNavigation(sessionId, scrollToBottom = true) + // A notification open is not a list navigation: back keeps the classic + // root-chat exit flow. + queueChatNavigation(sessionId, scrollToBottom = true, returnToList = false) } fun openNewChat() { @@ -91,8 +116,18 @@ object NavigationController { private fun queueChatNavigation( sessionId: String, scrollToBottom: Boolean, + returnToList: Boolean, ) { if (sessionId.isBlank()) return + // Snapshot the origin BEFORE navigateTo resets the stack to [ChatScreen]. + val returnScreen = + if (returnToList) { + backStack + ?.lastOrNull() + ?.takeIf { it != ChatScreen && isPrimaryScreen(it) } + } else { + null + } pendingChatNavigation = PendingChatNavigation( sessionId = sessionId, @@ -100,6 +135,8 @@ object NavigationController { requestId = ++nextChatNavigationRequestId, ) navigateTo(ChatScreen) + // Set after navigateTo so the reset itself does not disarm the return. + chatReturnScreen = returnScreen } fun consumePendingChatNavigation(): PendingChatNavigation? = @@ -113,6 +150,7 @@ object NavigationController { /** Clear the stack and navigate to the given screen atomically. */ fun resetTo(screen: NavKey) { val stack = backStack ?: return + chatReturnScreen = null stack.clear() stack.add(screen) } diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 9579944e..774dc7d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -23,6 +23,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } @After @@ -30,6 +31,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -171,6 +173,92 @@ class NavigationControllerTest { assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true) } + // ── Chat return screen: back from a list-opened chat returns to that list ── + + @Test + fun `openChatSession from a list arms the list as the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSession("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + assertEquals(HistoryScreen, NavigationController.consumeChatReturnScreen()) + assertNull("consume must disarm", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from Bots arms Bots`() { + NavigationController.backStack = NavBackStack(BotsScreen) + + NavigationController.openChatSession("bot-session") + + assertEquals(BotsScreen, NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from chat arms nothing`() { + NavigationController.backStack = NavBackStack(ChatScreen) + + NavigationController.openChatSession("stored-session") + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `notification open never arms a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSessionFromNotification("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `drawer navigation disarms a stale return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // User opens the drawer and picks Skills instead of pressing back. + NavigationController.navigateTo(SkillsScreen) + + assertNull("explicit navigation must invalidate the return", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `re-tapping chat in the drawer also disarms the return`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // Same-screen dedup would skip the body — the clear must still run. + NavigationController.navigateTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openNewChat does not arm a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openNewChat() + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `resetTo disarms the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + NavigationController.resetTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test