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() + } +}