Merge 11097a629a19637e8df3aa5aec40e7e1ad6777f4 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Moe Hamade 2026-09-15 03:01:22 +09:00 committed by GitHub
commit fcbc8734ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 118 additions and 17 deletions

View File

@ -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)
}

View File

@ -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<Boolean> = 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

View File

@ -387,6 +387,7 @@ class ChatViewModel(
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val canHandleBack = state.canHandleBack
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions

View File

@ -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)
}
}