From 2332bdd5d5d5df8a9d28dbaca0dfc90257620211 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:38:01 +0300 Subject: [PATCH] feat(nav): root swipe-back on chat with double-back exit and subpage hierarchy (#1001) Co-authored-by: M57 --- .../java/com/m57/hermescontrol/Navigation.kt | 23 +++ .../m57/hermescontrol/NavigationController.kt | 49 +++--- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../m57/hermescontrol/E2eIntegrationTest.kt | 10 +- .../hermescontrol/NavigationControllerTest.kt | 157 +++++++++--------- 7 files changed, 141 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index cb20c51c..be5727ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol +import android.app.Activity +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -31,10 +33,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -191,7 +196,25 @@ fun MainNavigation(sessionId: String? = null) { val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } + val context = LocalContext.current + var lastBackPressTime by remember { mutableLongStateOf(0L) } + val backToastText = stringResource(R.string.press_back_again_to_exit) + + // Double-back to exit on root ChatScreen BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) { + val now = System.currentTimeMillis() + if (now - lastBackPressTime < 2000L) { + (context as? Activity)?.finish() + } else { + lastBackPressTime = now + Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + } + } + + // Safety fallback: if a non-chat screen somehow sits alone on stack, swipe back returns to ChatScreen + BackHandler( + enabled = currentScreen != ChatScreen && currentScreen != LandingScreen && backStack.size == 1, + ) { NavigationController.goBack() } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 8659dcbc..16ddc35f 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -31,30 +31,37 @@ object NavigationController { private var nextChatNavigationRequestId = 0L - // Top-level primary screens (Chat, Skills, Cron, System, Settings) - private val primaryScreens: MutableSet = - mutableSetOf( - ChatScreen, - SkillsScreen, - CronJobsScreen, - SystemScreen, - SettingsScreen, - ) - - /** Returns whether the given key is a primary top-level screen. */ - fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens + /** + * Top-level primary screens (all drawer-accessible screens). + * Navigating to any of these clears the stack to `[ChatScreen, key]` (or `[ChatScreen]` for Chat), + * ensuring swiping back returns to ChatScreen. + */ + fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key } fun navigateTo(key: NavKey) { val stack = backStack ?: return if (stack.lastOrNull() == key) return + if (key == ChatScreen) { + stack.clear() + stack.add(ChatScreen) + return + } + + if (key == LandingScreen) { + stack.clear() + stack.add(LandingScreen) + return + } + if (isPrimaryScreen(key)) { stack.clear() + stack.add(ChatScreen) + stack.add(key) + return } - // Drawer dismissal is handled by DrawerGestureController (issue #619): - // when a non-gesture sub-page composes, its HermesScaffold reconciles - // drawerGesturesEnabled=false and the controller closes the drawer - // itself via SideEffect. No synchronous closeDrawer callback here. + + // Subscreen / drill-down navigation: append to current stack stack.add(key) } @@ -98,15 +105,13 @@ object NavigationController { */ fun goBack(fallback: NavKey = ChatScreen) { val stack = backStack ?: return - if (stack.lastOrNull() == ChatScreen) { - resetTo(HistoryScreen) - return - } if (stack.size > 1) { stack.removeLastOrNull() } else if (stack.size == 1) { - stack.clear() - stack.add(fallback) + if (stack.lastOrNull() != fallback && stack.lastOrNull() != LandingScreen) { + stack.clear() + stack.add(fallback) + } } } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b43199b2..18ba68fb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -49,6 +49,7 @@ 충전 + 종료하려면 뒤로가기를 한 번 더 누르세요 헤르메스 AI 에이전트 제어 대화 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 7f4f646a..e015cf23 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -55,6 +55,7 @@ 剩余额度:%1$s 套餐 充值 + 再次返回以退出应用 Hermes AI Agent 控制台 对话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc801afe..77e22e62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,7 @@ Top-up + Press back again to exit Hermes AI Agent Control Converse diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index ffe83b49..5540298d 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -718,17 +718,19 @@ class E2eIntegrationTest { NavigationController.navigateTo(SkillsScreen) assertEquals(SkillsScreen, backStack.lastOrNull()) - assertEquals(1, backStack.size) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack[0]) NavigationController.navigateTo(CronJobsScreen) assertEquals(CronJobsScreen, backStack.lastOrNull()) - assertEquals(1, backStack.size) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack[0]) - // Non-clearing drawer screen adds to stack + // Drawer screen navigation always roots on ChatScreen NavigationController.navigateTo(ProfilesScreen) assertEquals(ProfilesScreen, backStack.lastOrNull()) assertEquals(2, backStack.size) - assertEquals(CronJobsScreen, backStack[0]) + assertEquals(ChatScreen, backStack[0]) } // ── Tier 2: Boundary & Corner Cases (>=5 per feature) ──────────────── diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index b92167a2..4013dd96 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -12,15 +12,8 @@ import org.junit.Test /** * Unit tests for [NavigationController] — the central navigation guard that - * prevents duplicate screen entries on the back stack. - * - * Issue #291 (Critical Test Coverage): Verifies the deduplication logic at - * line 45 (`if (stack.lastOrNull() == key) return`) and the primary-screen - * stack-clearing behavior at line 47-49. - * - * All tests are pure Kotlin with no Android dependencies — they only exercise - * [NavigationController]'s companion object methods against a real - * [NavBackStack] instance. + * prevents duplicate screen entries on the back stack and enforces + * swipe-back-to-chat hierarchy. */ class NavigationControllerTest { @Before @@ -47,7 +40,7 @@ class NavigationControllerTest { @Test fun `navigateTo on same primary screen is a no-op`() { - val backStack = NavBackStack(SkillsScreen) + val backStack = NavBackStack(ChatScreen, SkillsScreen) NavigationController.backStack = backStack val sizeBefore = backStack.size @@ -58,69 +51,60 @@ class NavigationControllerTest { } @Test - fun `navigateTo on same non-primary screen is a no-op`() { - val backStack = NavBackStack(ProfilesScreen) + fun `navigateTo on same subscreen is a no-op`() { + val backStack = NavBackStack(ChatScreen, SettingsScreen, SettingsAppearance) NavigationController.backStack = backStack - // ProfilesScreen is NOT in the default primaryScreens so it stays on the stack - NavigationController.navigateTo(ProfilesScreen) + NavigationController.navigateTo(SettingsAppearance) - assertEquals(1, backStack.size) + assertEquals(3, backStack.size) + assertEquals(SettingsAppearance, backStack.lastOrNull()) + } + + // ── Primary-screen behaviour: resets stack to [ChatScreen, Target] ─────── + + @Test + fun `navigateTo on a different primary screen resets stack with ChatScreen as root`() { + val backStack = NavBackStack(ChatScreen) + NavigationController.backStack = backStack + + NavigationController.navigateTo(SkillsScreen) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack.firstOrNull()) + assertEquals(SkillsScreen, backStack.lastOrNull()) + + // Now navigate to another primary screen (e.g. ProfilesScreen) + NavigationController.navigateTo(ProfilesScreen) + assertEquals("primary screen navigation should reset stack to [ChatScreen, target]", 2, backStack.size) + assertEquals(ChatScreen, backStack.firstOrNull()) assertEquals(ProfilesScreen, backStack.lastOrNull()) } - // ── Primary-screen behaviour: stack clearing ────────────────────────── - @Test - fun `navigateTo on a different primary screen clears the stack`() { - val backStack = NavBackStack(ChatScreen) + fun `navigateTo ChatScreen clears stack to single ChatScreen root`() { + val backStack = NavBackStack(ChatScreen, ProfilesScreen) NavigationController.backStack = backStack - // Navigate to a non-primary screen first (should add to stack) - NavigationController.navigateTo(LogsScreen) - assertEquals(2, backStack.size) - - // Now navigate to a different primary screen — must clear - NavigationController.navigateTo(SkillsScreen) - - assertEquals("primary screen navigation should clear the stack", 1, backStack.size) - assertEquals(SkillsScreen, backStack.lastOrNull()) - } - - @Test - fun `navigateTo on the current primary screen clears the stack`() { - val backStack = NavBackStack(ChatScreen) - NavigationController.backStack = backStack - - // Add a non-primary screen - NavigationController.navigateTo(ProfilesScreen) - assertEquals(2, backStack.size) - - // Navigate to ChatScreen — it's a primary screen, so the stack gets - // cleared before adding it. The dedup guard only fires when the key - // is already the LAST item (ChatScreen is at index 0, ProfilesScreen - // is last), not when it's merely present somewhere in the stack. NavigationController.navigateTo(ChatScreen) - // Primary screen navigation clears the stack - assertEquals("primary navigation clears stack", 1, backStack.size) + assertEquals("chat navigation should result in single root entry", 1, backStack.size) assertEquals(ChatScreen, backStack.lastOrNull()) } - // ── Non-primary screen behaviour: stack appending ───────────────────── + // ── Subscreen behaviour: appends to stack ─────────────────────────────── @Test - fun `navigateTo on a non-primary screen appends to the stack`() { - val backStack = NavBackStack(ChatScreen) + fun `navigateTo on a subscreen appends to the current stack`() { + val backStack = NavBackStack(ChatScreen, SettingsScreen) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) - assertEquals(2, backStack.size) - assertEquals(ProfilesScreen, backStack.lastOrNull()) - - NavigationController.navigateTo(KeysScreen) + NavigationController.navigateTo(SettingsAppearance) assertEquals(3, backStack.size) - assertEquals(KeysScreen, backStack.lastOrNull()) + assertEquals(SettingsAppearance, backStack.lastOrNull()) + + NavigationController.navigateTo(SettingsAbout) + assertEquals(4, backStack.size) + assertEquals(SettingsAbout, backStack.lastOrNull()) } @Test @@ -176,13 +160,9 @@ class NavigationControllerTest { @Test fun `resetTo clears the stack and sets the target screen`() { - val backStack = NavBackStack(ChatScreen) + val backStack = NavBackStack(ChatScreen, ProfilesScreen, KeysScreen) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) - NavigationController.navigateTo(KeysScreen) - assertEquals(3, backStack.size) - NavigationController.resetTo(SettingsScreen) assertEquals(1, backStack.size) @@ -197,14 +177,17 @@ class NavigationControllerTest { assertNull(NavigationController.backStack) } - // ── goBack: never leave the stack empty ─────────────────────────────── + // ── goBack: pops stack, preserves chat root ─────────────────────────── @Test fun `goBack removes the top screen when stack has more than one`() { - val backStack = NavBackStack(ChatScreen) + val backStack = NavBackStack(ChatScreen, SettingsScreen, SettingsAppearance) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) + + NavigationController.goBack() + assertEquals(2, backStack.size) + assertEquals(SettingsScreen, backStack.lastOrNull()) NavigationController.goBack() @@ -213,18 +196,18 @@ class NavigationControllerTest { } @Test - fun `goBack from chat opens history`() { + fun `goBack on root ChatScreen keeps ChatScreen`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.goBack() assertEquals(1, backStack.size) - assertEquals(HistoryScreen, backStack.lastOrNull()) + assertEquals(ChatScreen, backStack.lastOrNull()) } @Test - fun `goBack falls back to default screen when stack has one item`() { + fun `goBack falls back to default screen when non-root stack has one item`() { val backStack = NavBackStack(ProfilesScreen) NavigationController.backStack = backStack @@ -256,18 +239,42 @@ class NavigationControllerTest { // ── primaryScreens ──────────────────────────────────────────────────── @Test - fun `isPrimaryScreen returns true for default screens`() { - assertTrue("ChatScreen should be primary by default", NavigationController.isPrimaryScreen(ChatScreen)) - assertTrue("SkillsScreen should be primary by default", NavigationController.isPrimaryScreen(SkillsScreen)) - assertTrue("CronJobsScreen should be primary by default", NavigationController.isPrimaryScreen(CronJobsScreen)) - assertTrue("SystemScreen should be primary by default", NavigationController.isPrimaryScreen(SystemScreen)) - assertTrue("SettingsScreen should be primary by default", NavigationController.isPrimaryScreen(SettingsScreen)) + fun `isPrimaryScreen returns true for all drawer screens`() { + assertTrue("ChatScreen should be primary", NavigationController.isPrimaryScreen(ChatScreen)) + assertTrue("SkillsScreen should be primary", NavigationController.isPrimaryScreen(SkillsScreen)) + assertTrue("CronJobsScreen should be primary", NavigationController.isPrimaryScreen(CronJobsScreen)) + assertTrue("SystemScreen should be primary", NavigationController.isPrimaryScreen(SystemScreen)) + assertTrue("SettingsScreen should be primary", NavigationController.isPrimaryScreen(SettingsScreen)) + assertTrue("ProfilesScreen should be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) + assertTrue("LogsScreen should be primary", NavigationController.isPrimaryScreen(LogsScreen)) + assertTrue("ConfigScreen should be primary", NavigationController.isPrimaryScreen(ConfigScreen)) + assertTrue("BotsScreen should be primary", NavigationController.isPrimaryScreen(BotsScreen)) + assertTrue("HistoryScreen should be primary", NavigationController.isPrimaryScreen(HistoryScreen)) } @Test - fun `isPrimaryScreen returns false for non-default screens`() { - assertFalse("ProfilesScreen should NOT be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) - assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) - assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) + fun `isPrimaryScreen returns false for subscreen detail keys`() { + assertFalse( + "SettingsAppearance should NOT be primary", + NavigationController.isPrimaryScreen(SettingsAppearance), + ) + assertFalse( + "SettingsConnection should NOT be primary", + NavigationController.isPrimaryScreen(SettingsConnection), + ) + assertFalse("SettingsLanguage should NOT be primary", NavigationController.isPrimaryScreen(SettingsLanguage)) + assertFalse("SettingsChat should NOT be primary", NavigationController.isPrimaryScreen(SettingsChat)) + assertFalse("SettingsBehavior should NOT be primary", NavigationController.isPrimaryScreen(SettingsBehavior)) + assertFalse("SettingsAbout should NOT be primary", NavigationController.isPrimaryScreen(SettingsAbout)) + assertFalse( + "ToolsetDetailKey should NOT be primary", + NavigationController.isPrimaryScreen(ToolsetDetailKey("tool")), + ) + assertFalse( + "MemoryProviderDetailKey should NOT be primary", + NavigationController.isPrimaryScreen(MemoryProviderDetailKey("mem")), + ) + assertFalse("GroupChatKey should NOT be primary", NavigationController.isPrimaryScreen(GroupChatKey("room"))) + assertFalse("AuthLoginScreen should NOT be primary", NavigationController.isPrimaryScreen(AuthLoginScreen)) } }