From cac953c7244645204b164cd1362ade2c2b35f286 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 1 Sep 2026 14:34:08 -0300 Subject: [PATCH] Utilize events instead of direct calls in main nav view model. --- .../thoughtcrime/securesms/MainActivity.kt | 74 ++++----- .../thoughtcrime/securesms/MainNavigator.java | 3 +- .../links/details/CallLinkDetailsScreen.kt | 21 +-- .../securesms/calls/log/CallLogFragment.kt | 5 +- .../ConversationSettingsFragment.kt | 16 +- .../ConversationSettingsNavigator.kt | 7 +- .../conversation/v2/ConversationActivity.kt | 29 +++- .../conversation/v2/ConversationFragment.kt | 18 ++- .../ConversationListArchiveFragment.java | 3 +- .../ConversationListFragment.java | 5 +- .../securesms/main/MainNavigationEventSink.kt | 17 ++ .../securesms/main/MainNavigationEvents.kt | 52 +++++++ .../securesms/main/MainNavigationRouter.kt | 33 ---- .../securesms/main/MainNavigationViewModel.kt | 147 +++++++----------- .../stories/landing/StoriesLandingFragment.kt | 7 +- .../main/MainNavigationViewModelTest.kt | 114 +++++++------- 16 files changed, 286 insertions(+), 265 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEventSink.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEvents.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index d2a240666c..d0262d86b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.withContext import org.signal.core.ui.BottomSheetUtil import org.signal.core.ui.NavigationType import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.split.ListDetailEvents import org.signal.core.ui.compose.split.ListDetailNavDisplay import org.signal.core.ui.compose.split.ListDetailPaneLayout import org.signal.core.ui.compose.split.ListDetailPaneMetrics @@ -145,8 +146,9 @@ import org.thoughtcrime.securesms.main.MainDetailRoute import org.thoughtcrime.securesms.main.MainListRoute import org.thoughtcrime.securesms.main.MainMegaphoneState import org.thoughtcrime.securesms.main.MainNavigationBar +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.main.MainNavigationRail -import org.thoughtcrime.securesms.main.MainNavigationRouter import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbar import org.thoughtcrime.securesms.main.MainSnackbarHostKey @@ -188,7 +190,7 @@ class MainActivity : MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, - MainNavigationRouter, + MainNavigationEventSink, CallLogFragment.Callback, GooglePayComponent { @@ -263,7 +265,7 @@ class MainActivity : private val mainBottomChromeCallback = BottomChromeCallback() private val megaphoneActionController = MainMegaphoneActionController() - private val mainNavigationCallback = MainNavigationCallback() + private val mainNavigationCallback: (MainListRoute) -> Unit = { mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(it)) } override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) } override val googlePayResultPublisher: Subject = PublishSubject.create() @@ -287,7 +289,7 @@ class MainActivity : AppForegroundObserver.addListener(object : AppForegroundObserver.Listener { override fun onForeground() { - mainNavigationViewModel.getNextMegaphone() + mainNavigationViewModel.onEvent(MainNavigationEvents.RequestNextMegaphone) } }) @@ -397,7 +399,7 @@ class MainActivity : val isBackHandlerEnabled = mainToolbarState.destination != MainListRoute.Chats && !isActionModeActive && !isSearchModeActive BackHandler(enabled = isBackHandlerEnabled) { - mainNavigationViewModel.goTo(MainListRoute.Chats) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) } BackHandler(enabled = isActionModeActive) { @@ -491,8 +493,8 @@ class MainActivity : entries = tabEntries, isSplitPane = isSplitPane, paneAnchor = paneAnchor, - onBack = { mainNavigationViewModel.popCurrentDetailLocation() }, - onExitDetail = { mainNavigationViewModel.exitDetailLocation() }, + onBack = { mainNavigationViewModel.onEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) }, + onExitDetail = { mainNavigationViewModel.onEvent(MainNavigationEvents.ExitDetail) }, layout = paneLayout, listPaneChrome = listPaneChrome, emptyDetailContent = emptyDetailContent, @@ -540,7 +542,7 @@ class MainActivity : return rememberListDetailPaneLayout( paneAnchor = paneAnchor, maxWidth = maxWidth, - onAnchorSelected = { mainNavigationViewModel.onPaneAnchorSelected(it) }, + onAnchorSelected = { mainNavigationViewModel.onEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(it))) }, metrics = contentLayoutData, // Searching hides the rail, leaving nothing of the list pane behind once the detail fills the window. collapsedListWidth = when { @@ -690,29 +692,20 @@ class MainActivity : val extras = intent.extras ?: return if (extras.getBoolean(KEY_EXIT_DETAIL, false)) { - mainNavigationViewModel.exitDetailLocation() + mainNavigationViewModel.onEvent(MainNavigationEvents.ExitDetail) return } val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainDetailRoute::class.java) if (detailLocation != null) { - goTo(detailLocation) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(detailLocation)) return } - val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java) + val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java) ?: return - when (startingTab) { - MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected() - MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected() - MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected() - MainListRoute.Stories -> { - if (Stories.isFeatureEnabled()) { - mainNavigationViewModel.onStoriesSelected() - } - } - - null -> Unit + if (startingTab != MainListRoute.Stories || Stories.isFeatureEnabled()) { + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(startingTab)) } } @@ -749,7 +742,7 @@ class MainActivity : } vitalsViewModel.checkSlowNotificationHeuristics() - mainNavigationViewModel.refreshNavigationBarState() + mainNavigationViewModel.onEvent(MainNavigationEvents.RefreshNavigationBar) CallQuality.consumeQualityRequest()?.let { CallQualityBottomSheetFragment.create(it).show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) @@ -775,7 +768,7 @@ class MainActivity : if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) { mainNavigationViewModel.snackbarRegistry.emit(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created), hostKey = MainSnackbarHostKey.MainChrome)) - mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL) + mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL)) } if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) { @@ -796,7 +789,7 @@ class MainActivity : hostKey = MainSnackbarHostKey.MainChrome ) ) - mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY) + mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY)) } } @@ -896,8 +889,8 @@ class MainActivity : return } - mainNavigationViewModel.goTo(MainListRoute.Chats) - mainNavigationViewModel.goTo(MainDetailRoute.Conversation(ConversationIntents.readArgsFromBundle(extras))) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Conversation(ConversationIntents.readArgsFromBundle(extras)))) intent.action = null setIntent(intent) } @@ -1033,7 +1026,7 @@ class MainActivity : } override fun onOpenArchiveClick() { - mainNavigationViewModel.onArchiveSelected() + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(MainListRoute.Archive)) } override fun onStarredMessagesClick() { @@ -1069,11 +1062,11 @@ class MainActivity : } override fun onStoryPrivacyClick() { - mainNavigationViewModel.goTo(MainDetailRoute.Stories.PrivacySettings) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.PrivacySettings)) } override fun onStoryArchiveClick() { - mainNavigationViewModel.goTo(MainDetailRoute.Stories.Archive) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.Archive)) } override fun onCloseSearchClick() { @@ -1125,7 +1118,7 @@ class MainActivity : } override fun onMegaphoneVisible(megaphone: Megaphone) { - mainNavigationViewModel.onMegaphoneVisible(megaphone) + mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneVisible(megaphone)) } override fun onSnackbarDismissed() = Unit @@ -1154,11 +1147,11 @@ class MainActivity : } override fun onMegaphoneSnooze(event: Megaphones.Event) { - mainNavigationViewModel.onMegaphoneSnoozed(event) + mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(event)) } override fun onMegaphoneCompleted(event: Megaphones.Event) { - mainNavigationViewModel.onMegaphoneCompleted(event) + mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneCompleted(event)) } override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) { @@ -1166,18 +1159,5 @@ class MainActivity : } } - private inner class MainNavigationCallback : (MainListRoute) -> Unit { - override fun invoke(location: MainListRoute) { - when (location) { - MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected() - MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected() - MainListRoute.Stories -> mainNavigationViewModel.onStoriesSelected() - MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected() - } - } - } - - override fun goTo(location: MainListRoute) = mainNavigationViewModel.goTo(location) - override fun goTo(location: MainDetailRoute) = mainNavigationViewModel.goTo(location) - override fun exitDetailLocation() = mainNavigationViewModel.exitDetailLocation() + override fun onEvent(event: MainNavigationEvents) = mainNavigationViewModel.onEvent(event) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java index 0720af294d..d1b3885cde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; import org.thoughtcrime.securesms.main.MainDetailRoute; +import org.thoughtcrime.securesms.main.MainNavigationEvents; import org.thoughtcrime.securesms.main.MainNavigationViewModel; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -50,7 +51,7 @@ public class MainNavigator { .withStartingPosition(startingPosition) .asIncognito(incognito) .toConversationArgs()) - .subscribe(args -> viewModel.goTo(new MainDetailRoute.Conversation(args))); + .subscribe(args -> viewModel.onEvent(new MainNavigationEvents.GoToDetail(new MainDetailRoute.Conversation(args)))); lifecycleDisposable.add(disposable); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt index 2d94b0555b..456406ce8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt @@ -47,7 +47,8 @@ import org.thoughtcrime.securesms.calls.links.CallLinks import org.thoughtcrime.securesms.calls.links.SignalCallRow import org.thoughtcrime.securesms.database.CallLinkTable import org.thoughtcrime.securesms.main.MainDetailRoute -import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials @@ -63,7 +64,7 @@ fun CallLinkDetailsScreen( viewModel: CallLinkDetailsViewModel = viewModel { CallLinkDetailsViewModel(roomId) }, - router: MainNavigationCallDetailRouter = viewModel(viewModelStoreOwner = LocalActivity.current as ComponentActivity) { + eventSink: MainNavigationEventSink = viewModel(viewModelStoreOwner = LocalActivity.current as ComponentActivity) { error("Should already be created.") } ) { @@ -72,7 +73,7 @@ fun CallLinkDetailsScreen( DefaultCallLinkDetailsCallback( activity = activity, viewModel = viewModel, - router = router + eventSink = eventSink ) } @@ -90,7 +91,7 @@ fun CallLinkDetailsScreen( class DefaultCallLinkDetailsCallback( private val activity: FragmentActivity, private val viewModel: CallLinkDetailsViewModel, - private val router: MainNavigationCallDetailRouter + private val eventSink: MainNavigationEventSink ) : CallLinkDetailsCallback { private val lifecycleDisposable = LifecycleDisposable() @@ -113,10 +114,12 @@ class DefaultCallLinkDetailsCallback( } override fun onEditNameClicked() { - router.goToCallDetail( - MainDetailRoute.Calls.CallLinks.EditCallLinkName( - callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(), - currentName = viewModel.nameSnapshot + eventSink.onEvent( + MainNavigationEvents.GoToDetail( + MainDetailRoute.Calls.CallLinks.EditCallLinkName( + callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(), + currentName = viewModel.nameSnapshot + ) ) ) } @@ -157,7 +160,7 @@ class DefaultCallLinkDetailsCallback( viewModel.setDisplayRevocationDialog(false) activity.lifecycleScope.launch { if (viewModel.delete()) { - router.exitDetailLocation() + eventSink.onEvent(MainNavigationEvents.ExitDetail) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index c9a9110822..b6d697ecbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.main.MainDetailRoute import org.thoughtcrime.securesms.main.MainListRoute +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.main.MainToolbarMode @@ -335,7 +336,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) { viewModel.toggleSelected(callLogRow.id) } else { - mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(callLogRow.record.roomId)) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.CallLinkDetails(callLogRow.record.roomId))) } } @@ -390,7 +391,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal } override fun goToCallLinkDetails(roomId: CallLinkRoomId) { - mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(roomId)) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.CallLinkDetails(roomId))) } override fun deleteCall(call: CallLogRow) { 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 60e9049310..388fc33b3f 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 @@ -64,7 +64,8 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob -import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache @@ -117,7 +118,7 @@ class ConversationSettingsFragment : ComposeFragment() { ) private var transitionCallback: TransitionCallback? = null - private var chatRouter: MainNavigationChatDetailRouter? = null + private var mainNavigationEventSink: MainNavigationEventSink? = null /** The avatar view owns the shared element transition out of this screen. */ private var avatarView: View? = null @@ -130,7 +131,7 @@ class ConversationSettingsFragment : ComposeFragment() { override fun onAttach(context: Context) { super.onAttach(context) transitionCallback = context as? TransitionCallback - chatRouter = context as? MainNavigationChatDetailRouter + mainNavigationEventSink = context as? MainNavigationEventSink } override fun onCreate(savedInstanceState: Bundle?) { @@ -358,7 +359,7 @@ class ConversationSettingsFragment : ComposeFragment() { val builder = ConversationIntents.createBuilderSync(requireContext(), action.recipientId, action.threadId) startActivity(builder.withSearchOpen(action.withSearchOpen).build()) - if (action.withSearchOpen && requireActivity() !is MainNavigationChatDetailRouter) { + if (action.withSearchOpen && requireActivity() !is MainNavigationEventSink) { requireActivity().finish() } } @@ -453,7 +454,7 @@ class ConversationSettingsFragment : ComposeFragment() { } is ConversationSettingsAction.OpenGroupConversation -> { CommunicationActions.startConversation(requireActivity(), action.recipient, null) - if (requireActivity() !is MainNavigationChatDetailRouter) { + if (requireActivity() !is MainNavigationEventSink) { requireActivity().finish() } } @@ -558,8 +559,9 @@ class ConversationSettingsFragment : ComposeFragment() { } private fun goToConversationList() { - if (chatRouter != null) { - chatRouter?.exitDetailLocation() + val eventSink = mainNavigationEventSink + if (eventSink != null) { + eventSink.onEvent(MainNavigationEvents.ExitDetail) } else { startActivity(MainActivity.clearTopAndExitDetail(requireContext())) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt index 19e510dee1..54b0fe585d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt @@ -7,7 +7,8 @@ package org.thoughtcrime.securesms.components.settings.conversation import androidx.fragment.app.FragmentActivity import org.thoughtcrime.securesms.main.MainDetailRoute -import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.recipients.Recipient /** @@ -19,8 +20,8 @@ object ConversationSettingsNavigator { activity: FragmentActivity, recipient: Recipient ) { - if (activity is MainNavigationChatDetailRouter) { - activity.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)) + if (activity is MainNavigationEventSink) { + activity.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))) return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index d4e92f9bfa..24430cba38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -28,7 +28,8 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob import org.thoughtcrime.securesms.main.MainDetailRoute -import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import java.util.concurrent.TimeUnit @@ -36,7 +37,7 @@ import java.util.concurrent.TimeUnit /** * Wrapper activity for ConversationFragment. */ -open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent, MainNavigationChatDetailRouter { +open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent, MainNavigationEventSink { companion object { private val TAG = tag(ConversationActivity::class.java) @@ -141,13 +142,33 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo .commitNowAllowingStateLoss() } - override fun exitDetailLocation() { + /** + * Only the events a conversation displayed on its own can answer. The rest belong to the main window, + * which is where the same screens send them when it is the one hosting them. + */ + override fun onEvent(event: MainNavigationEvents) { + when (event) { + MainNavigationEvents.ExitDetail -> exitDetail() + is MainNavigationEvents.GoToDetail -> { + val route = event.route + if (route is MainDetailRoute.Chats) { + goToChatDetail(route) + } else { + Log.w(TAG, "Cannot display $route outside of the main window. Ignoring it.") + } + } + + else -> Log.w(TAG, "Unsupported outside of the main window: $event. Ignoring it.") + } + } + + private fun exitDetail() { if (!supportFragmentManager.popBackStackImmediate()) { finish() } } - override fun goToChatDetail(location: MainDetailRoute.Chats) { + private fun goToChatDetail(location: MainDetailRoute.Chats) { when (location) { is MainDetailRoute.Chats.ConversationSettings -> { lifecycleScope.launch { 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 ef411da366..cc2a65e44b 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 @@ -298,7 +298,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2 import org.thoughtcrime.securesms.longmessage.LongMessageFragment import org.thoughtcrime.securesms.main.MainDetailRoute import org.thoughtcrime.securesms.main.MainListRoute -import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter +import org.thoughtcrime.securesms.main.MainNavigationEventSink +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity @@ -581,7 +582,7 @@ class ConversationFragment : private lateinit var conversationItemDecorations: ConversationItemDecorations private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback - private lateinit var chatRouter: MainNavigationChatDetailRouter + private lateinit var mainNavigationEventSink: MainNavigationEventSink private var animationsAllowed = false private var pinnedShortcutReceiver: BroadcastReceiver? = null @@ -662,7 +663,7 @@ class ConversationFragment : override fun onAttach(context: Context) { super.onAttach(context) - chatRouter = context as MainNavigationChatDetailRouter + mainNavigationEventSink = context as MainNavigationEventSink } override fun onCreate(savedInstanceState: Bundle?) { @@ -3004,7 +3005,7 @@ class ConversationFragment : ConversationDialogs.displayDeleteDialog(requireContext(), recipient) { messageRequestViewModel .onDelete() - .doAfterSuccess { chatRouter.exitDetailLocation() } + .doAfterSuccess { mainNavigationEventSink.onEvent(MainNavigationEvents.ExitDetail) } .subscribeWithShowProgress("delete message request") } } @@ -3151,7 +3152,8 @@ class ConversationFragment : private fun handleDisplayDetails(conversationMessage: ConversationMessage) { val recipientSnapshot = viewModel.recipientSnapshot ?: return - chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id))) + val messageDetails = MainDetailRoute.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)) + mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(messageDetails)) } private fun handleDeleteMessages(messageParts: Set) { @@ -3707,7 +3709,7 @@ class ConversationFragment : } else if (messageRecord.hasFailedWithNetworkFailures()) { ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord) } else { - chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientId, MessageId(messageRecord.id))) + mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))) } } @@ -4388,7 +4390,7 @@ class ConversationFragment : override fun handleManageGroup() { viewModel.recipientSnapshot?.let { recipient -> container.hideKeyboard(composeText) - chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)) + mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))) } } @@ -4426,7 +4428,7 @@ class ConversationFragment : viewModel.recipientSnapshot?.let { recipient -> if (!viewModel.hasMessageRequestState || recipient.isBlocked) { container.hideKeyboard(composeText) - chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)) + mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index 3fe801a44e..f08ff2072b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.snackbars.SnackbarState; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.main.MainListRoute; +import org.thoughtcrime.securesms.main.MainNavigationEvents; import org.thoughtcrime.securesms.main.MainSnackbarHostKey; import org.thoughtcrime.securesms.util.ConversationUtil; @@ -77,7 +78,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - mainNavigationViewModel.goTo(MainListRoute.Chats); + mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Chats)); } }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index c35daf6a96..bfd640865f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -144,6 +144,7 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.main.MainListRoute; +import org.thoughtcrime.securesms.main.MainNavigationEvents; import org.thoughtcrime.securesms.main.MainNavigationViewModel; import org.thoughtcrime.securesms.main.MainSnackbarHostKey; import org.thoughtcrime.securesms.main.MainToolbarMode; @@ -702,7 +703,7 @@ public class ConversationListFragment extends MainFragment implements Conversati @Override public void onShowArchiveClick() { if (viewModel.currentSelectedConversations().isEmpty()) { - mainNavigationViewModel.goTo(MainListRoute.Archive); + mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Archive)); } } @@ -765,7 +766,7 @@ public class ConversationListFragment extends MainFragment implements Conversati } else if (event instanceof MainToolbarViewModel.Event.Chats.ClearFilter) { onClearFilterClick(); } else if (event instanceof MainToolbarViewModel.Event.Chats.CloseArchive) { - mainNavigationViewModel.goTo(MainListRoute.Chats); + mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Chats)); } }) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEventSink.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEventSink.kt new file mode 100644 index 0000000000..22aa51660c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEventSink.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +/** + * An activity that hosts main navigation content and can answer its events. + * + * The main window hands them to [MainNavigationViewModel]. The standalone conversation activity answers + * the few that still mean something outside the main window with its own fragment transactions. Screens + * that either one can host send their events here, and check for it to tell the two hosts apart. + */ +fun interface MainNavigationEventSink { + fun onEvent(event: MainNavigationEvents) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEvents.kt new file mode 100644 index 0000000000..fa8cac2b41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationEvents.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import org.signal.core.ui.compose.split.ListDetailEvents +import org.thoughtcrime.securesms.megaphone.Megaphone +import org.thoughtcrime.securesms.megaphone.Megaphones + +/** + * UI Events to drive the main navigation view model. + */ +sealed interface MainNavigationEvents { + + /** + * The user clicked [tab] in the navigation bar or rail. Clicking the tab already displayed reveals its + * list and tells that tab's screen about the click, rather than navigating anywhere. + */ + data class GoToTab(val tab: MainListRoute) : MainNavigationEvents + + /** Display [route], which comes back to whatever detail content its tab had open. */ + data class GoToList(val route: MainListRoute) : MainNavigationEvents + + /** Open [route] above the list of whichever tab owns it. */ + data class GoToDetail(val route: MainDetailRoute) : MainNavigationEvents + + /** Drop the detail above the displayed list, leaving that list on its own. */ + data object ExitDetail : MainNavigationEvents + + /** Open the camera straight into story capture. */ + data object GoToCameraFirstStoryCapture : MainNavigationEvents + + /** Re-read the settings that shape the navigation bar. */ + data object RefreshNavigationBar : MainNavigationEvents + + /** Ask for the next megaphone to display, if there is one. */ + data object RequestNextMegaphone : MainNavigationEvents + + /** [megaphone] was displayed to the user. */ + data class MegaphoneVisible(val megaphone: Megaphone) : MainNavigationEvents + + /** The user put [event]'s megaphone off until later. */ + data class MegaphoneSnoozed(val event: Megaphones.Event) : MainNavigationEvents + + /** [event]'s megaphone is done with, and should not come back. */ + data class MegaphoneCompleted(val event: Megaphones.Event) : MainNavigationEvents + + /** Something that is navigation and nothing else, so the navigator can answer it unaided. */ + data class ListDetailEvent(val event: ListDetailEvents) : MainNavigationEvents +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt deleted file mode 100644 index d278c35d70..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.main - -/** - * Handles navigation for sub-screens within the chats detail pane. - */ -interface MainNavigationChatDetailRouter { - fun exitDetailLocation() - fun goToChatDetail(location: MainDetailRoute.Chats) -} - -/** - * Handles navigation for sub-screens within the calls detail pane. - */ -interface MainNavigationCallDetailRouter { - fun exitDetailLocation() - fun goToCallDetail(location: MainDetailRoute.Calls) -} - -/** - * Handles navigation to all [MainListRoute]s and [MainDetailRoute]s, including the top-level roots. - */ -interface MainNavigationRouter : MainNavigationChatDetailRouter, MainNavigationCallDetailRouter { - fun goTo(location: MainListRoute) - fun goTo(location: MainDetailRoute) - - override fun goToChatDetail(location: MainDetailRoute.Chats) = goTo(location) - override fun goToCallDetail(location: MainDetailRoute.Calls) = goTo(location) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 48a9415a39..aef907c3ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx3.asObservable import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.ui.compose.split.ListDetailEvents import org.signal.core.ui.compose.split.ListDetailNavigator import org.signal.core.ui.compose.split.PaneAnchor @@ -49,7 +50,7 @@ import kotlin.time.Duration.Companion.milliseconds class MainNavigationViewModel( savedStateHandle: SavedStateHandle, initialListLocation: MainListRoute = MainListRoute.Chats -) : ViewModel(), MainNavigationRouter { +) : EventDrivenViewModel(TAG), MainNavigationEventSink { companion object { private val TAG = Log.tag(MainNavigationViewModel::class) @@ -156,11 +157,20 @@ class MainNavigationViewModel( } } - /** - * The user dragged the pane divider to [anchor]. - */ - fun onPaneAnchorSelected(anchor: PaneAnchor) { - navigator.processEvent(ListDetailEvents.AnchorSelected(anchor)) + override suspend fun processEvent(event: MainNavigationEvents) { + when (event) { + is MainNavigationEvents.GoToTab -> goToTab(event.tab) + is MainNavigationEvents.GoToList -> goToList(event.route) + is MainNavigationEvents.GoToDetail -> goToDetail(event.route) + MainNavigationEvents.ExitDetail -> navigator.processEvent(ListDetailEvents.ExitDetail) + MainNavigationEvents.GoToCameraFirstStoryCapture -> internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST) + MainNavigationEvents.RefreshNavigationBar -> refreshNavigationBarState() + MainNavigationEvents.RequestNextMegaphone -> requestNextMegaphone() + is MainNavigationEvents.MegaphoneVisible -> megaphoneRepository.markVisible(event.megaphone.event) + is MainNavigationEvents.MegaphoneSnoozed -> onMegaphoneSnoozed(event.event) + is MainNavigationEvents.MegaphoneCompleted -> onMegaphoneCompleted(event.event) + is MainNavigationEvents.ListDetailEvent -> navigator.processEvent(event.event) + } } /** Set from the MainActivity composition, and cleared when it is disposed. */ @@ -168,7 +178,38 @@ class MainNavigationViewModel( captureChatListSnapshot = capture } - override fun goTo(location: MainDetailRoute) = setDetailLocation(location) + /** + * Clicking the tab already displayed reveals its list and tells that tab's screen about the click — + * scrolling to the top, and the like — rather than navigating anywhere. + */ + private suspend fun goToTab(tab: MainListRoute) { + if (navigator.displayedList.value == tab) { + navigator.processEvent(ListDetailEvents.RevealList) + internalTabClickEvents.emit(tab) + } else { + goToList(tab) + } + } + + /** Switching tabs only changes which stack is displayed; each tab comes back to whatever it had open. */ + private fun goToList(route: MainListRoute) { + navigator.processEvent( + ListDetailEvents.GoToList( + listRoute = route, + root = route.tab, + push = route == MainListRoute.Archive + ) + ) + } + + private suspend fun goToDetail(route: MainDetailRoute) { + when (route) { + is MainDetailRoute.Chats -> pushChatsDetailLocation(route) + is MainDetailRoute.Conversation -> goToConversation(route) + is MainDetailRoute.Calls, is MainDetailRoute.CallLinkDetails -> pushCallsDetailLocation(route) + is MainDetailRoute.Stories -> navigator.processEvent(ListDetailEvents.Push(route, MainListRoute.Stories)) + } + } private suspend fun MainDetailRoute.Conversation.withPreloadedWallpaper(): MainDetailRoute.Conversation { val args = conversationArgs @@ -196,33 +237,15 @@ class MainNavigationViewModel( return copy(conversationArgs = updatedArgs) } - private fun setDetailLocation(location: MainDetailRoute) { - when (location) { - is MainDetailRoute.Chats -> pushChatsDetailLocation(location) - is MainDetailRoute.Conversation -> goToConversation(location) - is MainDetailRoute.Calls, is MainDetailRoute.CallLinkDetails -> pushCallsDetailLocation(location) - is MainDetailRoute.Stories -> pushStoriesDetailLocation(location) - } - } - - /** - * Drops the detail content above the current list, leaving that list displayed on its own. - */ - override fun exitDetailLocation() { - navigator.processEvent(ListDetailEvents.ExitDetail) - } - - private fun goToConversation(location: MainDetailRoute.Conversation) { + private suspend fun goToConversation(location: MainDetailRoute.Conversation) { val captureSnapshot = captureChatListSnapshot if (captureSnapshot == null) { - // share intent or process restore - push synchronously, since there's no chat-list snapshot to capture and no need to preload a wallpaper + // share intent or process restore - there's no chat-list snapshot to capture and no need to preload a wallpaper pushChatsDetailLocation(location) } else { - viewModelScope.launch { - captureSnapshot() - pushChatsDetailLocation(location.withPreloadedWallpaper()) - } + captureSnapshot() + pushChatsDetailLocation(location.withPreloadedWallpaper()) } } @@ -244,53 +267,23 @@ class MainNavigationViewModel( navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Calls)) } - private fun pushStoriesDetailLocation(location: MainDetailRoute) { - navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Stories)) - } - - /** Pops the stack belonging to whichever tab the user is currently on. */ - fun popCurrentDetailLocation() { - navigator.processEvent(ListDetailEvents.Back) - } - - /** Switching tabs only changes which stack is displayed; each tab comes back to whatever it had open. */ - override fun goTo(location: MainListRoute) { - navigator.processEvent( - ListDetailEvents.GoToList( - listRoute = location, - root = location.tab, - push = location == MainListRoute.Archive - ) - ) - } - - fun goToCameraFirstStoryCapture() { - viewModelScope.launch { - internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST) - } - } - - fun getNextMegaphone() { + private fun requestNextMegaphone() { megaphoneRepository.getNextMegaphone { next -> internalMegaphone.update { next ?: Megaphone.NONE } } } - fun onMegaphoneSnoozed(event: Megaphones.Event) { + private fun onMegaphoneSnoozed(event: Megaphones.Event) { megaphoneRepository.markInteractedWith(event) internalMegaphone.update { Megaphone.NONE } } - fun onMegaphoneCompleted(event: Megaphones.Event) { + private fun onMegaphoneCompleted(event: Megaphones.Event) { internalMegaphone.update { Megaphone.NONE } megaphoneRepository.markFinished(event) } - fun onMegaphoneVisible(visible: Megaphone) { - megaphoneRepository.markVisible(visible.event) - } - - fun refreshNavigationBarState() { + private fun refreshNavigationBarState() { internalMainNavigationBarState.update { it.copy( compact = SignalStore.settings.useCompactNavigationBar, @@ -305,34 +298,6 @@ class MainNavigationViewModel( return notificationProfilesRepository.getProfiles().asFlow() } - fun onChatsSelected() { - onTabSelected(MainListRoute.Chats) - } - - fun onArchiveSelected() { - onTabSelected(MainListRoute.Archive) - } - - fun onCallsSelected() { - onTabSelected(MainListRoute.Calls) - } - - fun onStoriesSelected() { - onTabSelected(MainListRoute.Stories) - } - - private fun onTabSelected(destination: MainListRoute) { - viewModelScope.launch { - val displayed = navigator.displayedList.value - if (displayed == destination) { - navigator.processEvent(ListDetailEvents.RevealList) - internalTabClickEvents.emit(destination) - } else { - goTo(destination) - } - } - } - private fun performStoreUpdate(flow: Flow, fn: (T, MainNavigationBarState) -> MainNavigationBarState) { viewModelScope.launch { flow.collectLatest { item -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index af4ece1fdd..ab50823dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.main.MainDetailRoute import org.thoughtcrime.securesms.main.MainListRoute +import org.thoughtcrime.securesms.main.MainNavigationEvents import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.main.MainToolbarViewModel @@ -188,7 +189,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l MyStoriesItem.Model( lifecycleOwner = viewLifecycleOwner, onClick = { - mainNavigationViewModel.goToCameraFirstStoryCapture() + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToCameraFirstStoryCapture) } ) ) @@ -258,7 +259,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l openStoryViewer(model, preview, true) }, onAvatarClick = { - mainNavigationViewModel.goToCameraFirstStoryCapture() + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToCameraFirstStoryCapture) }, onLockList = { recyclerView?.suppressLayout(true) @@ -271,7 +272,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l private fun openStoryViewer(model: StoriesLandingItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) { if (model.data.storyRecipient.isMyStory) { - mainNavigationViewModel.goTo(MainDetailRoute.Stories.MyStories) + mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories)) } else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) { if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) { SafetyNumberBottomSheet diff --git a/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt index 57668f7355..d480079f9e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt @@ -25,6 +25,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.signal.core.ui.compose.split.ListDetailEvents import org.signal.core.ui.compose.split.PaneAnchor import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageId @@ -33,9 +34,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule /** - * Covers the view model as the sole owner of the tab back stacks: everything a screen can ask for goes - * through [MainNavigationRouter] or [MainNavigationViewModel.popCurrentDetailLocation], and the stacks it - * hands the display are what those calls leave behind. + * Covers the view model as the sole owner of the tab back stacks: everything a screen can ask for arrives + * as a [MainNavigationEvents], and the stacks it hands the display are what those events leave behind. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -79,6 +79,12 @@ class MainNavigationViewModelTest { Dispatchers.resetMain() } + /** Events are answered off the view model's own channel, so every one of them is drained before asserting. */ + private fun MainNavigationViewModel.sendEvent(event: MainNavigationEvents) { + onEvent(event) + testDispatcher.scheduler.advanceUntilIdle() + } + @Test fun `given a new view model, then chats is displayed with no detail`() { assertEquals(MainListRoute.Chats, viewModel.currentTab.value) @@ -87,41 +93,41 @@ class MainNavigationViewModelTest { @Test fun `when going to a detail location, then it is pushed onto the displayed tab`() { - viewModel.goTo(conversationSettings) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats]) } @Test fun `given stacked detail, when popping, then only the top of the stack is dropped`() { - viewModel.goTo(conversationSettings) - viewModel.goTo(messageDetails) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails)) - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats]) } @Test fun `given stacked detail, when exiting detail, then all of it is dropped`() { - viewModel.goTo(conversationSettings) - viewModel.goTo(messageDetails) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails)) - viewModel.exitDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ExitDetail) assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats]) } @Test fun `given a stack at its root, when popping, then nothing is dropped`() { - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats]) } @Test fun `when going to the archive, then it is pushed onto the chats stack rather than becoming a tab`() { - viewModel.goTo(MainListRoute.Archive) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive)) assertEquals(MainListRoute.Chats, viewModel.currentTab.value) assertEquals( @@ -132,9 +138,9 @@ class MainNavigationViewModelTest { @Test fun `given the archive is displayed, when going back to chats, then the archive is popped`() { - viewModel.goTo(MainListRoute.Archive) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive)) - viewModel.goTo(MainListRoute.Chats) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats]) } @@ -145,10 +151,10 @@ class MainNavigationViewModelTest { */ @Test fun `given detail opened from the archive, when exiting detail, then the archive stays displayed`() { - viewModel.goTo(MainListRoute.Archive) - viewModel.goTo(conversationSettings) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) - viewModel.exitDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ExitDetail) assertEquals( listOf(MainListRoute.Chats, MainListRoute.Archive), @@ -158,9 +164,9 @@ class MainNavigationViewModelTest { @Test fun `given detail is open, when opening the archive, then the detail stays displayed above it`() { - viewModel.goTo(conversationSettings) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) - viewModel.goTo(MainListRoute.Archive) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive)) assertEquals( listOf(MainListRoute.Chats, MainListRoute.Archive, conversationSettings), @@ -170,8 +176,8 @@ class MainNavigationViewModelTest { @Test fun `when going to a calls destination, then it is pushed onto the calls stack`() { - viewModel.goTo(MainListRoute.Calls) - viewModel.goTo(callLinkDetails) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails)) assertEquals(MainListRoute.Calls, viewModel.currentTab.value) assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls]) @@ -183,11 +189,11 @@ class MainNavigationViewModelTest { */ @Test fun `given detail open on another tab, when switching away and back, then that stack is unchanged`() { - viewModel.goTo(MainListRoute.Calls) - viewModel.goTo(callLinkDetails) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails)) - viewModel.goTo(MainListRoute.Chats) - viewModel.goTo(MainListRoute.Calls) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls]) } @@ -198,12 +204,12 @@ class MainNavigationViewModelTest { */ @Test fun `given detail open on both tabs, when popping from calls, then only the calls stack is affected`() { - viewModel.goTo(MainListRoute.Chats) - viewModel.goTo(conversationSettings) - viewModel.goTo(MainListRoute.Calls) - viewModel.goTo(callLinkDetails) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails)) - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls]) assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats]) @@ -211,12 +217,12 @@ class MainNavigationViewModelTest { @Test fun `given detail open on both tabs, when exiting detail from calls, then only the calls stack is affected`() { - viewModel.goTo(MainListRoute.Chats) - viewModel.goTo(conversationSettings) - viewModel.goTo(MainListRoute.Calls) - viewModel.goTo(callLinkDetails) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails)) - viewModel.exitDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ExitDetail) assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls]) assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats]) @@ -224,12 +230,12 @@ class MainNavigationViewModelTest { @Test fun `given detail open on another tab, when popping from chats, then only the chats stack is affected`() { - viewModel.goTo(MainListRoute.Calls) - viewModel.goTo(callLinkDetails) - viewModel.goTo(MainListRoute.Chats) - viewModel.goTo(conversationSettings) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails)) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats]) assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls]) @@ -237,8 +243,8 @@ class MainNavigationViewModelTest { @Test fun `when going to a stories destination, then it is pushed onto the stories stack`() { - viewModel.goTo(MainListRoute.Stories) - viewModel.goTo(MainDetailRoute.Stories.MyStories) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Stories)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories)) assertEquals( listOf(MainListRoute.Stories, MainDetailRoute.Stories.MyStories), @@ -252,10 +258,10 @@ class MainNavigationViewModelTest { */ @Test fun `given a stories destination is open, when opening another, then it replaces the first`() { - viewModel.goTo(MainListRoute.Stories) - viewModel.goTo(MainDetailRoute.Stories.MyStories) + viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Stories)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories)) - viewModel.goTo(MainDetailRoute.Stories.PrivacySettings) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.PrivacySettings)) assertEquals( listOf(MainListRoute.Stories, MainDetailRoute.Stories.PrivacySettings), @@ -265,30 +271,30 @@ class MainNavigationViewModelTest { @Test fun `given the list fills the window, when detail content opens, then the detail is revealed`() { - viewModel.onPaneAnchorSelected(PaneAnchor.LIST_ONLY) + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.LIST_ONLY))) - viewModel.goTo(conversationSettings) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value) } @Test fun `given the detail fills the window, when the last detail is popped, then the list is revealed`() { - viewModel.goTo(conversationSettings) - viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))) - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(PaneAnchor.LIST_ONLY, viewModel.paneAnchor.value) } @Test fun `given stacked detail, when the top is popped, then the detail pane stays revealed`() { - viewModel.goTo(conversationSettings) - viewModel.goTo(messageDetails) - viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings)) + viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails)) + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))) - viewModel.popCurrentDetailLocation() + viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value) }