From 5d289d8d9760488314abfe08edf1e49e4593bdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl?= Date: Fri, 31 Jul 2026 11:32:38 +0200 Subject: [PATCH] fix(healthchat): stop tab back handler swallowing back presses While a screen is pushed on top of Home, the still-composed tab BackHandler intercepted back presses during the transition and invisibly switched to the Chats tab. Gate it on the node's target visibility in both the parent and Home's own backstack. --- .../features/home/impl/HomeFlowNode.kt | 16 ++++ .../android/features/home/impl/HomeView.kt | 8 +- .../home/impl/HomeViewBackHandlingTest.kt | 78 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index aad998b3c1..5336e08d70 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -12,6 +12,7 @@ import android.app.Activity import android.os.Parcelable import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +23,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.LocalNodeTargetVisibility import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin @@ -102,6 +104,10 @@ class HomeFlowNode( private val callback: HomeEntryPoint.Callback = callback() private val stateFlow = launchMolecule { presenter.present() } + // HealthChat fork: whether this node is the visible target within the parent backstack. + // False as soon as another screen (e.g. a member profile) starts being pushed on top. + private val isVisibleNavTarget = mutableStateOf(true) + override fun onBuilt() { super.onBuilt() lifecycle.subscribe( @@ -262,6 +268,9 @@ class HomeFlowNode( modifier = Modifier, ) }, + // Both levels: Home within the parent backstack (captured in View) and the + // root element within Home's own backstack. + isBackHandlingEnabled = isVisibleNavTarget.value && LocalNodeTargetVisibility.current, ) directLogoutView.Render(state.directLogoutState) } @@ -269,6 +278,13 @@ class HomeFlowNode( @Composable override fun View(modifier: Modifier) { + // HealthChat fork: capture the target visibility at this level, where the + // CompositionLocal still refers to Home within the PARENT backstack (inside + // rootNode it is re-provided for Home's own backstack). + val isVisibleTarget = LocalNodeTargetVisibility.current + SideEffect { + isVisibleNavTarget.value = isVisibleTarget + } BackstackView() } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 9c4932cf92..ed4475c250 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -90,6 +90,9 @@ fun HomeView( leaveRoomView: @Composable () -> Unit, // HealthChat fork: content of the member directory tab, rendered by features/healthchat. memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit = { _, _ -> }, + // HealthChat fork: false while another screen is pushed on top of Home, so the + // tab-switching BackHandler cannot swallow back presses during the transition. + isBackHandlingEnabled: Boolean = true, ) { val state: RoomListState = homeState.roomListState val coroutineScope = rememberCoroutineScope() @@ -125,6 +128,7 @@ fun HomeView( onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, memberDirectoryView = memberDirectoryView, + isBackHandlingEnabled = isBackHandlingEnabled, ) // This overlaid view will only be visible when state.displaySearchResults is true RoomListSearchView( @@ -152,6 +156,7 @@ private fun HomeScaffold( onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, memberDirectoryView: @Composable (PaddingValues, LazyListState) -> Unit, + isBackHandlingEnabled: Boolean, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -163,7 +168,8 @@ private fun HomeScaffold( val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val roomListState: RoomListState = state.roomListState - BackHandler(enabled = state.isBackHandlerEnabled) { + // HealthChat fork: also gated on isBackHandlingEnabled (see HomeView docs). + BackHandler(enabled = state.isBackHandlerEnabled && isBackHandlingEnabled) { if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) { state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) } else { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt new file mode 100644 index 0000000000..f4f45856f0 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomeViewBackHandlingTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.home.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.pressBackKey +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class HomeViewBackHandlingTest : RobolectricTest() { + @Test + fun `back press on the members tab switches to the chats tab`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setContent { + HomeView( + homeState = aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, + eventSink = eventsRecorder, + ), + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onCreateSpaceClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onReportRoomClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {}, + ) + } + pressBackKey() + eventsRecorder.assertSingle(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) + } + + @Test + fun `back press is not intercepted while home is not the visible nav target`() = runAndroidComposeUiTest { + // Regression test: while another screen (e.g. a member profile) is being pushed on + // top of Home, the tab BackHandler must not swallow the back press by invisibly + // switching tabs — the back press has to reach the parent backstack pop instead. + val eventsRecorder = EventsRecorder(expectEvents = false) + setContent { + HomeView( + homeState = aHomeState( + currentHomeNavigationBarItem = HomeNavigationBarItem.Members, + eventSink = eventsRecorder, + ), + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onCreateSpaceClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onReportRoomClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {}, + isBackHandlingEnabled = false, + ) + } + pressBackKey() + } +}