From c0fe2dfdc028ae56c13a2319c7cf144bb823b74f Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 4 Nov 2025 15:56:10 -0400 Subject: [PATCH] Add BackHandler compatibility layer. --- .../compose/FragmentBackPressedState.kt | 78 +++++++++++++++++++ .../conversation/v2/ConversationFragment.kt | 35 +++++++-- .../securesms/main/ChatsNavHost.kt | 7 ++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/compose/FragmentBackPressedState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/compose/FragmentBackPressedState.kt b/app/src/main/java/org/thoughtcrime/securesms/compose/FragmentBackPressedState.kt new file mode 100644 index 0000000000..d6f38b2d0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/compose/FragmentBackPressedState.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.annotation.RememberInComposition +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Allows us to support view based fragments hosted in a compose-based activity having their + * own back handling. + */ +@Stable +class FragmentBackPressedState @RememberInComposition constructor() { + var info by mutableStateOf(null) + + fun attach(fragment: Fragment) { + if (fragment is FragmentBackPressedInfoProvider) { + with(fragment) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(state = Lifecycle.State.CREATED) { + getFragmentBackPressedInfo().collect { + info = it + } + } + } + } + } + } +} + +/** + * Describes the current back-pressed state, produced by a [Fragment] + */ +sealed interface FragmentBackPressedInfo { + object Disabled : FragmentBackPressedInfo + data class Enabled(val callback: () -> Unit) : FragmentBackPressedInfo +} + +/** + * Fragment should implement this interface. + */ +interface FragmentBackPressedInfoProvider { + fun getFragmentBackPressedInfo(): Flow +} + +/** + * BackHandler for interop with legacy style fragments. + * Don't forget to call [FragmentBackPressedState.attach]! + */ +@Composable +fun FragmentBackHandler(state: FragmentBackPressedState) { + val info = state.info + val enabled = when (info) { + FragmentBackPressedInfo.Disabled -> false + is FragmentBackPressedInfo.Enabled -> true + null -> false + } + + BackHandler(enabled = enabled) { + if (info is FragmentBackPressedInfo.Enabled) { + info.callback() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 1572f6eed4..8b49988b92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -86,11 +86,13 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus @@ -153,6 +155,8 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView +import org.thoughtcrime.securesms.compose.FragmentBackPressedInfo +import org.thoughtcrime.securesms.compose.FragmentBackPressedInfoProvider import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey.RecipientSearchKey import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.contactshare.ContactUtil @@ -393,7 +397,8 @@ class ConversationFragment : SafetyNumberBottomSheet.Callbacks, EnableCallNotificationSettingsDialog.Callback, MultiselectForwardBottomSheet.Callback, - DoubleTapEditEducationSheet.Callback { + DoubleTapEditEducationSheet.Callback, + FragmentBackPressedInfoProvider { companion object { private val TAG = Log.tag(ConversationFragment::class.java) @@ -935,6 +940,18 @@ class ConversationFragment : override fun onDismissForwardSheet() = Unit + override fun getFragmentBackPressedInfo(): Flow { + return viewModel.backPressedState.map { + if (it.shouldHandleBackPressed()) { + FragmentBackPressedInfo.Enabled({ + BackPressedCallback().handleOnBackPressed() + }) + } else { + FragmentBackPressedInfo.Disabled + } + } + } + //endregion private fun startActionMode() { @@ -1034,13 +1051,15 @@ class ConversationFragment : activity?.supportStartPostponedEnterTransition() internalDidFirstFrameRender.update { true } - val backPressedDelegate = BackPressedDelegate() - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedDelegate) + if (requireActivity() is ConversationActivity) { + val backPressedCallback = BackPressedCallback() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.backPressedState.collectLatest { - backPressedDelegate.isEnabled = it.shouldHandleBackPressed() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.backPressedState.collectLatest { + backPressedCallback.isEnabled = it.shouldHandleBackPressed() + } } } } @@ -2489,7 +2508,7 @@ class ConversationFragment : } } - private inner class BackPressedDelegate : OnBackPressedCallback(false) { + private inner class BackPressedCallback : OnBackPressedCallback(false) { override fun handleOnBackPressed() { Log.d(TAG, "onBackPressed()") val state = viewModel.backPressedState.value diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt index 8dda3996f0..86ec9e12e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -44,6 +44,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.thoughtcrime.securesms.MainNavigator +import org.thoughtcrime.securesms.compose.FragmentBackHandler +import org.thoughtcrime.securesms.compose.FragmentBackPressedState import org.thoughtcrime.securesms.conversation.ConversationArgs import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.v2.ConversationFragment @@ -106,6 +108,9 @@ fun NavGraphBuilder.chatNavGraphBuilder( ) } + val backPressedState = remember { FragmentBackPressedState() } + FragmentBackHandler(backPressedState) + AndroidFragment( clazz = ConversationFragment::class.java, fragmentState = fragmentState, @@ -127,6 +132,8 @@ fun NavGraphBuilder.chatNavGraphBuilder( } } + backPressedState.attach(fragment) + fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.repeatOnLifecycle(Lifecycle.State.STARTED) { fragment.didFirstFrameRender.collectLatest {