diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index d75fca96f7..6248a1c33e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -118,6 +118,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.NewConversationActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationFragment import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment @@ -192,7 +193,15 @@ import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDeleg import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState import org.signal.core.ui.R as CoreUiR -class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback, GooglePayComponent { +class MainActivity : + PassphraseRequiredActivity(), + VoiceNoteMediaControllerOwner, + MainNavigator.NavigatorProvider, + Material3OnScrollHelperBinder, + ConversationListFragment.Callback, + ConversationFragment.NavigationHost, + CallLogFragment.Callback, + GooglePayComponent { companion object { private val TAG = Log.tag(MainActivity::class) @@ -496,6 +505,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location) + is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location) } } @@ -1276,4 +1286,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } } } + + override fun navigateTo(location: MainNavigationDetailLocation) { + mainNavigationViewModel.goTo(location) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallInfoActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallInfoActivity.kt index 2c82cc3621..71d5cdd0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallInfoActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallInfoActivity.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.DynamicTheme -class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.Callback { +class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.TransitionCallback { override val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt index 0567a018af..1481a57869 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.View import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat -import androidx.core.util.Pair import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity @@ -17,7 +16,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme import org.thoughtcrime.securesms.util.DynamicTheme -open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback { +open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.TransitionCallback { override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme() @@ -27,7 +26,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet super.onCreate(savedInstanceState, ready) } - override fun onContentWillRender() { + override fun onReadyForEnterTransition() { ActivityCompat.startPostponedEnterTransition(this) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 1831517095..126ea7d3ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -120,6 +120,11 @@ private const val REQUEST_CODE_ADD_CONTACT = 2 private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3 private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4 +/** + * Settings screen for a conversation. + * + * Hosts that want shared element enter transitions should implement [TransitionCallback]. + */ class ConversationSettingsFragment : DSLSettingsFragment( layoutId = R.layout.conversation_settings_fragment, @@ -156,7 +161,7 @@ class ConversationSettingsFragment : } ) - private lateinit var callback: Callback + private var transitionCallback: TransitionCallback? = null private lateinit var toolbar: Toolbar private lateinit var toolbarAvatarContainer: FrameLayout @@ -172,8 +177,7 @@ class ConversationSettingsFragment : override fun onAttach(context: Context) { super.onAttach(context) - - callback = context as Callback + transitionCallback = context as? TransitionCallback } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -300,7 +304,7 @@ class ConversationSettingsFragment : adapter.submitList(getConfiguration(state).toMappingModelList()) { if (state.isLoaded) { (view?.parent as? ViewGroup)?.doOnPreDraw { - callback.onContentWillRender() + transitionCallback?.onReadyForEnterTransition() } } } @@ -1136,7 +1140,13 @@ class ConversationSettingsFragment : } } - interface Callback { - fun onContentWillRender() + /** + * Implemented by hosts that postpone enter transitions (for example, shared element flows). + * + * Called when this fragment has loaded enough UI state to safely run the postponed enter + * transition. + */ + interface TransitionCallback { + fun onReadyForEnterTransition() } } 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 4d42def3b1..442263a75d 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 @@ -564,6 +564,8 @@ class ConversationFragment : private lateinit var conversationItemDecorations: ConversationItemDecorations private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback + private var navigationHost: NavigationHost? = null + private var animationsAllowed = false private var pinnedShortcutReceiver: BroadcastReceiver? = null private var searchMenuItem: MenuItem? = null @@ -636,6 +638,11 @@ class ConversationFragment : //region Android Lifecycle + override fun onAttach(context: Context) { + super.onAttach(context) + navigationHost = context as? NavigationHost + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) SignalLocalMetrics.ConversationOpen.start() @@ -2904,11 +2911,7 @@ class ConversationFragment : private fun handleDisplayDetails(conversationMessage: ConversationMessage) { val recipientSnapshot = viewModel.recipientSnapshot ?: return - if (requireActivity() is MainActivity) { - mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, conversationMessage.messageRecord.id)) - } else { - MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG) - } + navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id))) } private fun handleDeleteMessages(messageParts: Set) { @@ -3436,10 +3439,8 @@ class ConversationFragment : .show(childFragmentManager) } else if (messageRecord.hasFailedWithNetworkFailures()) { ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord) - } else if (requireActivity() is MainActivity) { - mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, messageRecord.id)) } else { - MessageDetailsFragment.create(messageRecord, recipientId).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG) + navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, MessageId(messageRecord.id))) } } @@ -4059,15 +4060,9 @@ class ConversationFragment : } override fun handleManageGroup() { - val recipient = viewModel.recipientSnapshot ?: return - val intent = ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId()) - val bundle = ConversationSettingsActivity.createTransitionBundle( - requireContext(), - binding.conversationTitleView.root.findViewById(R.id.contact_photo_image), - binding.toolbar - ) - - requireActivity().startActivity(intent, bundle) + viewModel.recipientSnapshot?.let { recipient -> + navigateToConversationSettingsStandalone(recipient) + } } override fun handleLeavePushGroup() { @@ -4101,24 +4096,11 @@ class ConversationFragment : } override fun handleConversationSettings() { - val recipient = viewModel.recipientSnapshot ?: return - if (recipient.isGroup) { - handleManageGroup() - return + viewModel.recipientSnapshot?.let { recipient -> + if (!viewModel.hasMessageRequestState || recipient.isBlocked) { + navigateToConversationSettingsStandalone(recipient) + } } - - if (viewModel.hasMessageRequestState && !recipient.isBlocked) { - return - } - - val intent = ConversationSettingsActivity.forRecipient(requireContext(), recipient.id) - val bundle = ConversationSettingsActivity.createTransitionBundle( - requireActivity(), - binding.conversationTitleView.root.findViewById(R.id.contact_photo_image), - binding.toolbar - ) - - requireActivity().startActivity(intent, bundle) } override fun handleSelectMessageExpiration() { @@ -4173,6 +4155,49 @@ class ConversationFragment : } } + /** + * Routes to the appropriate destination based on the current window configuration. + * + * In split-pane mode, delegates to the [NavigationHost] to display content in the detail pane. Otherwise, opens the destination as a standalone screen. + */ + private fun navigateTo(location: MainNavigationDetailLocation.Chats) { + val host = navigationHost + if (host != null && resources.getWindowSizeClass().isSplitPane()) { + host.navigateTo(location) + } else { + when (location) { + is MainNavigationDetailLocation.Chats.MessageDetails -> navigateToMessageDetailsStandalone(location) + is MainNavigationDetailLocation.Chats.Conversation -> error("ConversationFragment shouldn't navigate to another conversation - use the main navigation infrastructure instead.") + } + } + } + + /** + * Opens message details as a standalone (single-pane) screen. Use [navigateTo] as the entry point. + */ + private fun navigateToMessageDetailsStandalone(location: MainNavigationDetailLocation.Chats.MessageDetails) { + MessageDetailsFragment.create(location.messageId, location.recipientId) + .show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG) + } + + /** + * Opens conversation settings as a standalone (single-pane) screen. + */ + private fun navigateToConversationSettingsStandalone(recipient: Recipient) { + val intent = if (recipient.isPushGroup) { + ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId()) + } else { + ConversationSettingsActivity.forRecipient(requireContext(), recipient.id) + } + + val bundle = ConversationSettingsActivity.createTransitionBundle( + requireActivity(), + binding.conversationTitleView.root.findViewById(R.id.contact_photo_image), + binding.toolbar + ) + requireActivity().startActivity(intent, bundle) + } + private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener { override fun onReactionSelected(messageRecord: MessageRecord, emoji: String?) { reactionDelegate.hide() @@ -5121,4 +5146,11 @@ class ConversationFragment : override fun onDoubleTapEditEducationSheetNext(conversationMessage: ConversationMessage) { handleEditMessage(conversationMessage) } + + /** + * Optional hook for host activity to intercept and handle detail navigation, used by split-pane layouts. + */ + interface NavigationHost { + fun navigateTo(location: MainNavigationDetailLocation) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index b31ecbdff4..553f21a58b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.database.model +import android.os.Bundle import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable /** * Represents the primary key in a [MessageId]. */ +@Serializable @Parcelize data class MessageId( val id: Long @@ -14,6 +17,13 @@ data class MessageId( return "$id|true" } + class NavType : androidx.navigation.NavType(false) { + override fun get(bundle: Bundle, key: String): MessageId? = bundle.getLong(key, -1).takeIf { it >= 0 }?.let { MessageId(it) } + override fun parseValue(value: String): MessageId = MessageId(value.toLong()) + override fun put(bundle: Bundle, key: String, value: MessageId) = bundle.putLong(key, value.id) + override fun serializeAsValue(value: MessageId): String = value.id.toString() + } + companion object { /** * Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that. 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 d41bae0b74..c70dca92b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -52,6 +52,7 @@ 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 +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.serialization.JsonSerializableNavType @@ -145,7 +146,8 @@ fun NavGraphBuilder.chatNavGraphBuilder( composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(RecipientId.serializer()) + typeOf() to JsonSerializableNavType(RecipientId.serializer()), + typeOf() to MessageId.NavType() ) ) { navBackStackEntry -> val context = LocalContext.current diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 7ed272af65..d9a51ce9f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.conversation.ConversationArgs +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId @@ -67,7 +68,7 @@ sealed class MainNavigationDetailLocation : Parcelable { } @Serializable - data class MessageDetails(val recipientId: RecipientId, val messageId: Long) : Chats() { + data class MessageDetails(val recipientId: RecipientId, val messageId: MessageId) : Chats() { @Transient @IgnoredOnParcel override val controllerKey: RecipientId = recipientId diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt index 3a40deee3b..a238f44563 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt @@ -15,7 +15,9 @@ import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager +import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log +import org.signal.core.util.requireParcelableCompat import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.WrapperDialogFragment @@ -30,6 +32,7 @@ import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog.Companion.show import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController @@ -92,9 +95,9 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks { } private fun initializeViewModel() { - val recipientId = requireArguments().getParcelable(RECIPIENT_EXTRA) - val messageId = requireArguments().getLong(MESSAGE_ID_EXTRA, -1) - val factory = MessageDetailsViewModel.Factory(recipientId, messageId) + val recipientId = requireArguments().getParcelableCompat(RECIPIENT_EXTRA, RecipientId::class.java) + val messageId = requireArguments().requireParcelableCompat(MESSAGE_ID_EXTRA, MessageId::class.java) + val factory = MessageDetailsViewModel.Factory(recipientId, messageId.id) viewModel = ViewModelProvider(this, factory)[MessageDetailsViewModel::class.java] viewModel.messageDetails.observe(viewLifecycleOwner) { details: MessageDetails? -> @@ -427,16 +430,16 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks { private const val MESSAGE_ID_EXTRA = "message_id" private const val RECIPIENT_EXTRA = "recipient_id" - fun args(recipientId: RecipientId, messageId: Long): Bundle { + fun args(recipientId: RecipientId, messageId: MessageId): Bundle { return bundleOf( MESSAGE_ID_EXTRA to messageId, RECIPIENT_EXTRA to recipientId ) } - fun create(message: MessageRecord, recipientId: RecipientId): Dialog { + fun create(messageId: MessageId, recipientId: RecipientId): Dialog { return Dialog().apply { - arguments = args(recipientId, message.id) + arguments = args(recipientId, messageId) } } }