Split MainNavigationRouter into focused domain-specific routers.

This commit is contained in:
jeffrey-signal
2026-05-06 10:16:41 -04:00
committed by Greyson Parrelli
parent 2ffbf09b1b
commit ed7fd10749
13 changed files with 138 additions and 113 deletions
@@ -85,7 +85,6 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
@@ -521,15 +520,14 @@ class MainActivity :
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> {
if (location is MainNavigationDetailLocation.Chats.Conversation) {
chatNavGraphState.writeGraphicsLayerToBitmap()
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -1034,7 +1032,7 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
setIntent(intent)
}
@@ -50,7 +50,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
lifecycleDisposable.add(disposable);
}
@@ -16,9 +16,8 @@ import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
@@ -56,23 +55,17 @@ class CallLinkDetailsActivity : FragmentActivity() {
}
}
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
is MainNavigationDetailLocation.Empty -> {
finishAfterTransition()
}
else -> error("Unsupported route $location")
}
}
override fun goTo(location: MainNavigationListLocation) = Unit
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -46,8 +46,8 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlrea
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.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
@@ -63,7 +63,7 @@ fun CallLinkDetailsScreen(
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
router: MainNavigationCallDetailRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
error("Should already be created.")
}
) {
@@ -90,7 +90,7 @@ fun CallLinkDetailsScreen(
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationRouter
private val router: MainNavigationCallDetailRouter
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
@@ -113,7 +113,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
@@ -152,7 +152,7 @@ class DefaultCallLinkDetailsCallback(
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.goTo(MainNavigationDetailLocation.Empty)
router.exitDetailLocation()
}
}
}
@@ -333,7 +333,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(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -92,8 +92,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.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
@@ -179,7 +179,7 @@ class ConversationSettingsFragment :
)
private var transitionCallback: TransitionCallback? = null
private var mainNavRouter: MainNavigationRouter? = null
private var chatRouter: MainNavigationChatDetailRouter? = null
private lateinit var toolbar: Toolbar
private lateinit var toolbarAvatarContainer: FrameLayout
@@ -196,7 +196,7 @@ class ConversationSettingsFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
transitionCallback = context as? TransitionCallback
mainNavRouter = context as? MainNavigationRouter
chatRouter = context as? MainNavigationChatDetailRouter
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -263,8 +263,8 @@ class ConversationSettingsFragment :
}
private fun goToConversationList() {
if (mainNavRouter != null) {
mainNavRouter?.goTo(MainNavigationDetailLocation.Empty)
if (chatRouter != null) {
chatRouter?.exitDetailLocation()
} else {
startActivity(MainActivity.clearTopAndOpenDetail(requireContext(), MainNavigationDetailLocation.Empty))
}
@@ -293,9 +293,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
@@ -582,7 +582,7 @@ class ConversationFragment :
private lateinit var conversationItemDecorations: ConversationItemDecorations
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
private var mainNavRouter: MainNavigationRouter? = null
private var chatRouter: MainNavigationChatDetailRouter? = null
private var animationsAllowed = false
private var pinnedShortcutReceiver: BroadcastReceiver? = null
@@ -661,7 +661,7 @@ class ConversationFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
mainNavRouter = context as? MainNavigationRouter
chatRouter = context as? MainNavigationChatDetailRouter
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -4413,17 +4413,16 @@ class ConversationFragment :
/**
* Routes to the appropriate destination based on the current window configuration.
*
* In split-pane mode, delegates to the [MainNavigationRouter] to display content in the detail pane. Otherwise, opens the destination as a standalone screen.
* In split-pane mode, delegates to the [MainNavigationChatDetailRouter] to display content in the detail pane. Otherwise, opens the destination as a standalone screen.
*/
private fun navigateTo(location: MainNavigationDetailLocation.Chats) {
val router = mainNavRouter
val router = chatRouter
if (router != null && resources.isSplitPane()) {
router.goTo(location)
router.goToChatDetail(location)
} else {
when (location) {
is MainNavigationDetailLocation.Chats.MessageDetails -> navigateToMessageDetailsStandalone(location)
is MainNavigationDetailLocation.Chats.ConversationSettings -> navigateToConversationSettingsStandalone(viewModel.recipientSnapshot!!)
is MainNavigationDetailLocation.Chats.Conversation -> error("ConversationFragment shouldn't navigate to another conversation - use the main navigation infrastructure instead.")
}
}
}
@@ -37,7 +37,7 @@ fun Fragment.listenToEventBusWhileResumed(
.collectLatest {
if (!resources.isSplitPane()) {
when (it) {
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
is MainNavigationDetailLocation.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
else -> Unit
}
@@ -28,14 +28,14 @@ fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
EmptyDetailScreen()
}
composable<MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails>(
composable<MainNavigationDetailLocation.CallLinkDetails>(
typeMap = mapOf(
callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
informNavigatorWeAreReady()
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails>()
val route = it.toRoute<MainNavigationDetailLocation.CallLinkDetails>()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
@@ -73,12 +73,12 @@ fun NavGraphBuilder.chatNavGraphBuilder(
EmptyDetailScreen()
}
composable<MainNavigationDetailLocation.Chats.Conversation>(
composable<MainNavigationDetailLocation.Conversation>(
typeMap = mapOf(
conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer())
)
) { navBackStackEntry ->
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Conversation>()
val fragmentState = key(route) { rememberFragmentState() }
val context = LocalContext.current
@@ -19,16 +19,16 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* Describes which content to display in the detail view.
* Describes which content to display in the detail pane.
*/
@Serializable
@Parcelize
sealed class MainNavigationDetailLocation : Parcelable {
sealed interface MainNavigationDetailLocation : Parcelable {
class Saver(
val earlyLocation: MainNavigationDetailLocation?
) : androidx.compose.runtime.saveable.Saver<MainNavigationDetailLocation, String> {
override fun SaverScope.save(value: MainNavigationDetailLocation): String? {
override fun SaverScope.save(value: MainNavigationDetailLocation): String {
return Json.encodeToString(value)
}
@@ -42,40 +42,55 @@ sealed class MainNavigationDetailLocation : Parcelable {
* of a task stack (or on top of Empty)
*/
@IgnoredOnParcel
open val isContentRoot: Boolean = false
val isContentRoot: Boolean
get() = false
@Serializable
data object Empty : MainNavigationDetailLocation() {
data object Empty : MainNavigationDetailLocation {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
}
@Serializable
data class Conversation(val conversationArgs: ConversationArgs) : MainNavigationDetailLocation {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
val controllerKey: Long = conversationArgs.threadId
}
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : MainNavigationDetailLocation {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
}
/**
* Subscreens that can be displayed within the chats tab.
*/
@Parcelize
sealed class Chats : MainNavigationDetailLocation() {
sealed interface Chats : MainNavigationDetailLocation {
abstract val controllerKey: RecipientId
val controllerKey: RecipientId
@Serializable
data class Conversation(val conversationArgs: ConversationArgs) : Chats() {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = conversationArgs.recipientId
}
@Serializable
data class MessageDetails(val recipientId: RecipientId, val messageId: MessageId) : Chats() {
data class MessageDetails(val recipientId: RecipientId, val messageId: MessageId) : Chats {
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = recipientId
}
@Serializable
data class ConversationSettings(val recipientId: RecipientId) : Chats() {
data class ConversationSettings(val recipientId: RecipientId) : Chats {
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = recipientId
@@ -83,27 +98,14 @@ sealed class MainNavigationDetailLocation : Parcelable {
}
/**
* Content which can be displayed while the user is navigating the Calls tab.
* Subscreens that can be displayed within the calls tab.
*/
@Parcelize
sealed class Calls : MainNavigationDetailLocation() {
abstract val controllerKey: CallLogRow.Id
sealed interface Calls : MainNavigationDetailLocation {
val controllerKey: CallLogRow.Id
@Parcelize
sealed class CallLinks : Calls() {
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
}
sealed class CallLinks : Calls {
@Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient
@@ -114,5 +116,5 @@ sealed class MainNavigationDetailLocation : Parcelable {
}
@Parcelize
sealed class Stories : MainNavigationDetailLocation()
sealed class Stories : MainNavigationDetailLocation
}
@@ -5,8 +5,30 @@
package org.thoughtcrime.securesms.main
interface MainNavigationRouter {
/**
* Handles navigation for sub-screens within the chats detail pane.
*/
interface MainNavigationChatDetailRouter {
fun exitDetailLocation()
fun goToChatDetail(location: MainNavigationDetailLocation.Chats)
}
/**
* Handles navigation for sub-screens within the calls detail pane.
*/
interface MainNavigationCallDetailRouter {
fun exitDetailLocation()
fun goToCallDetail(location: MainNavigationDetailLocation.Calls)
}
/**
* Handles navigation to all [MainNavigationListLocation]s and [MainNavigationDetailLocation]s, including the top-level roots.
*/
interface MainNavigationRouter : MainNavigationChatDetailRouter, MainNavigationCallDetailRouter {
fun goTo(location: MainNavigationListLocation)
fun goTo(location: MainNavigationDetailLocation)
fun goTo(location: MainNavigationListLocation)
override fun goToChatDetail(location: MainNavigationDetailLocation.Chats) = goTo(location)
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) = goTo(location)
override fun exitDetailLocation() = goTo(MainNavigationDetailLocation.Empty)
}
@@ -36,7 +36,6 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -146,8 +145,12 @@ class MainNavigationViewModel(
viewModelScope.launch {
internalDetailLocation.collect { location ->
when (location) {
is MainNavigationDetailLocation.Chats.Conversation -> {
internalActiveChatThreadId.update { location.conversationArgs.threadId }
is MainNavigationDetailLocation.Conversation -> {
internalActiveChatThreadId.update { location.controllerKey }
}
is MainNavigationDetailLocation.CallLinkDetails -> {
internalActiveCallId.update { location.controllerKey }
}
is MainNavigationDetailLocation.Calls -> {
@@ -224,37 +227,19 @@ class MainNavigationViewModel(
* render) *before* swapping panes. This helps to prevent flashing / duplicate loads.
*/
override fun goTo(location: MainNavigationDetailLocation) {
lockPaneToSecondary = false
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
}
when (location) {
is MainNavigationDetailLocation.Chats.Conversation -> goToConversation(location.conversationArgs)
else -> {
viewModelScope.launch {
internalDetailLocation.emit(location)
}
}
is MainNavigationDetailLocation.Empty,
is MainNavigationDetailLocation.Chats.ConversationSettings,
is MainNavigationDetailLocation.Chats.MessageDetails,
is MainNavigationDetailLocation.CallLinkDetails,
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> setDetailLocation(location)
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
}
}
override fun goTo(location: MainNavigationListLocation) {
lockPaneToSecondary = true
if (navigator == null) {
earlyNavigationListLocationRequested = location
return
}
internalMainNavigationState.update {
it.copy(currentListLocation = location)
}
}
private fun goToConversation(args: ConversationArgs) = viewModelScope.launch {
private fun goToConversation(location: MainNavigationDetailLocation.Conversation) = viewModelScope.launch {
val args = location.conversationArgs
val liveRecipient = Recipient.live(args.recipientId)
val recipientSnapshot = liveRecipient.get()
val wallpaper = recipientSnapshot.wallpaper
@@ -276,7 +261,33 @@ class MainNavigationViewModel(
args.copy(hasWallpaper = wallpaper != null)
}
internalDetailLocation.emit(MainNavigationDetailLocation.Chats.Conversation(updatedArgs))
setDetailLocation(MainNavigationDetailLocation.Conversation(updatedArgs))
}
private fun setDetailLocation(location: MainNavigationDetailLocation) {
lockPaneToSecondary = false
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
}
viewModelScope.launch {
internalDetailLocation.emit(location)
}
}
override fun goTo(location: MainNavigationListLocation) {
lockPaneToSecondary = true
if (navigator == null) {
earlyNavigationListLocationRequested = location
return
}
internalMainNavigationState.update {
it.copy(currentListLocation = location)
}
}
fun goToCameraFirstStoryCapture() {