diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a..3d752291 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -4,7 +4,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log -import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels @@ -313,23 +313,13 @@ class MainActivity : OrientationAwareActivity() { } OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { - // Set up back navigation handling for the chat screen - val backCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // Let ChatViewModel handle navigation state - val handled = chatViewModel.handleBackPressed() - if (!handled) { - // If ChatViewModel doesn't handle it, disable this callback - // and let the system handle it (which will exit the app) - this.isEnabled = false - onBackPressedDispatcher.onBackPressed() - this.isEnabled = true - } - } + val canHandleBack by chatViewModel.canHandleBack.collectAsState() + // Disabled rather than always-on so predictive back can preview + // the exit instead of the app claiming every gesture. + BackHandler(enabled = canHandleBack) { + // enabled trails the state by a dispatch and a recomposition. + if (!chatViewModel.handleBackPressed()) finish() } - - // Add the callback - this will be automatically removed when the activity is destroyed - onBackPressedDispatcher.addCallback(this, backCallback) ChatScreen(viewModel = chatViewModel) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index f4504cc2..16210894 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -170,6 +170,26 @@ class ChatState( initialValue = false ) + // Mirrors the branches of ChatViewModel.handleBackPressed. The back handler + // is enabled from this and the press consumed by that, so they must agree. + val canHandleBack: StateFlow = combine( + _showAppInfo, + _showPasswordPrompt, + _selectedPrivateChatPeer, + _privateChatSheetPeer, + _currentChannel + ) { showAppInfo, showPasswordPrompt, privateChatPeer, privateChatSheetPeer, channel -> + showAppInfo || + showPasswordPrompt || + privateChatPeer != null || + privateChatSheetPeer != null || + channel != null + }.stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) + // Getters for internal state access fun getMessagesValue() = _messages.value fun getConnectedPeersValue() = _connectedPeers.value diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f40549b6..e8ceace1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -387,6 +387,7 @@ class ChatViewModel( val passwordPromptChannel: StateFlow = state.passwordPromptChannel val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages + val canHandleBack = state.canHandleBack val showCommandSuggestions: StateFlow = state.showCommandSuggestions val commandSuggestions: StateFlow> = state.commandSuggestions val showMentionSuggestions: StateFlow = state.showMentionSuggestions diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ChatStateBackNavigationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ChatStateBackNavigationTest.kt new file mode 100644 index 00000000..5c5c41de --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ChatStateBackNavigationTest.kt @@ -0,0 +1,90 @@ +package com.bitchat.android.ui + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * `canHandleBack` must agree with the branches of + * [ChatViewModel.handleBackPressed]: the back handler is enabled from one and + * the press consumed by the other. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatStateBackNavigationTest { + + private lateinit var scope: TestScope + private lateinit var state: ChatState + private lateinit var subscription: Job + + @Before + fun setUp() { + scope = TestScope(UnconfinedTestDispatcher()) + state = ChatState(scope) + // Shared WhileSubscribed, so it only tracks its sources while collected. + subscription = scope.launch { state.canHandleBack.collect { } } + } + + @After + fun tearDown() { + subscription.cancel() + } + + @Test + fun `is false on the bare chat screen`() { + assertFalse(state.canHandleBack.value) + } + + @Test + fun `is true while the app info dialog is open`() { + state.setShowAppInfo(true) + + assertTrue(state.canHandleBack.value) + } + + @Test + fun `is true while the password prompt is open`() { + state.setShowPasswordPrompt(true) + + assertTrue(state.canHandleBack.value) + } + + @Test + fun `is true while a private chat is selected`() { + state.setSelectedPrivateChatPeer("peer-a") + + assertTrue(state.canHandleBack.value) + } + + @Test + fun `is true while the private chat sheet is open`() { + state.setPrivateChatSheetPeer("peer-a") + + assertTrue(state.canHandleBack.value) + } + + @Test + fun `is true while a channel is open`() { + state.setCurrentChannel("#bitchat") + + assertTrue(state.canHandleBack.value) + } + + @Test + fun `returns to false once the last overlay closes`() { + state.setCurrentChannel("#bitchat") + state.setShowAppInfo(true) + + state.setShowAppInfo(false) + assertTrue("the channel is still open", state.canHandleBack.value) + + state.setCurrentChannel(null) + assertFalse("nothing is left to unwind", state.canHandleBack.value) + } +}