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.
This commit is contained in:
2026-07-31 11:32:38 +02:00
parent d37b3a2359
commit 5d289d8d97
3 changed files with 101 additions and 1 deletions
@@ -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()
}
@@ -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 {
@@ -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<ComponentActivity> {
val eventsRecorder = EventsRecorder<HomeEvent>()
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<ComponentActivity> {
// 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<HomeEvent>(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()
}
}