From 5ed1bdc26a8c5b9ce3bdd2f1602b9fa04c739e48 Mon Sep 17 00:00:00 2001 From: Jeffrey Starke Date: Mon, 29 Jun 2026 08:28:05 -0400 Subject: [PATCH] Migrate calls tab to nav 3. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 +- .../thoughtcrime/securesms/MainActivity.kt | 42 +++++----- .../securesms/calls/CallsBackStack.kt | 64 ++++++++++++++++ .../securesms/calls/CallsNavigation.kt | 64 ++++++++++++++++ .../links/EditCallLinkNameDialogFragment.kt | 5 +- .../links/details/CallLinkDetailsActivity.kt | 71 ----------------- .../links/details/CallLinkDetailsScreen.kt | 7 +- .../links/details/CallLinkDetailsViewModel.kt | 7 -- .../securesms/calls/log/CallLogContextMenu.kt | 10 +-- .../securesms/calls/log/CallLogFragment.kt | 11 ++- .../securesms/chats/ChatsBackStack.kt | 29 +------ .../securesms/main/CallsNavHost.kt | 69 ----------------- .../securesms/main/MainDetailBackStack.kt | 36 +++++++++ .../main/MainNavigationDetailLocation.kt | 5 +- .../securesms/main/MainNavigationViewModel.kt | 76 ++++++++++--------- 16 files changed, 263 insertions(+), 241 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dad4dedbcf..3717375722 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -670,6 +670,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.process) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a27e7054e..eea157e261 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -438,12 +438,7 @@ android:theme="@style/Theme.Signal.DayNight.NoActionBar" android:windowSoftInputMode="adjustResize" /> - + callsNavHostController - MainNavigationListLocation.STORIES -> storiesNavHostController - }.navigateToDetailLocation(location) + MainNavigationListLocation.CALLS -> { + throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.") + } + + MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location) + } } is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> { throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.") } - is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location) - is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location) + is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> { + throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.") + } + is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location) } } @@ -739,9 +737,17 @@ class MainActivity : } MainNavigationListLocation.CALLS -> { - DetailsScreenNavHost( - navHostController = callsNavHostController, - contentLayoutData = contentLayoutData + NavDisplay( + backStack = mainNavigationViewModel.callsBackStackEntries, + onBack = { mainNavigationViewModel.popCallsDetailLocation() }, + transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec, + popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec, + predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { callsNavEntries(isSplitPane) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt new file mode 100644 index 0000000000..9097415080 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.calls + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi +import androidx.lifecycle.viewmodel.compose.saveable +import org.thoughtcrime.securesms.calls.log.CallLogRow +import org.thoughtcrime.securesms.main.MainDetailBackStack +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation + +/** + * Controls the navigation stack used by the calls screen. + */ +@OptIn(SavedStateHandleSaveableApi::class) +class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack { + + companion object { + private const val KEY = "calls_back_stack" + + val saver: Saver, ArrayList> = Saver( + save = { ArrayList(it) }, + restore = { mutableStateListOf(*it.toTypedArray()) } + ) + } + + override val entries: SnapshotStateList = savedStateHandle.saveable( + key = KEY, + saver = saver + ) { + mutableStateListOf(MainNavigationDetailLocation.Empty) + } + + val activeCallId: CallLogRow.Id? + get() = entries.asReversed().firstNotNullOfOrNull { location -> + when (location) { + is MainNavigationDetailLocation.Calls -> location.controllerKey + is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey + else -> null + } + } + + /** + * Pushes an entry onto the stack. + */ + override fun push(location: MainNavigationDetailLocation) { + when { + location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit + + location.isContentRoot -> { + entries.removeAll { it !is MainNavigationDetailLocation.Empty } + entries.add(location) + } + + else -> entries.add(location) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt new file mode 100644 index 0000000000..42cff81c30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.calls + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import org.signal.core.ui.navigation.TransitionSpecs +import org.thoughtcrime.securesms.MainNavigator +import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen +import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen +import org.thoughtcrime.securesms.main.EmptyDetailScreen +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation + +fun EntryProviderScope.callsNavEntries(isSplitPane: Boolean) { + entry { + NoCallSelectedEntry() + } + + entry( + metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap() + ) { route -> + CallLinkDetailsEntry(route) + } + + entry { route -> + EditCallLinkNameEntry(route) + } +} + +@Composable +private fun NoCallSelectedEntry() { + EmptyDetailScreen() +} + +@Composable +private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) { + informNavigatorWeAreReady() + + CallLinkDetailsScreen(roomId = route.callLinkRoomId) +} + +@Composable +private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) { + informNavigatorWeAreReady() + + EditCallLinkNameScreen( + roomId = route.callLinkRoomId, + initialName = route.currentName + ) +} + +@Composable +private fun informNavigatorWeAreReady() { + val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider + LaunchedEffect(navigator) { + navigator?.onFirstRender() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt index 1d9da50313..a29f6da7c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt @@ -89,7 +89,8 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() { @Composable fun EditCallLinkNameScreen( - roomId: CallLinkRoomId + roomId: CallLinkRoomId, + initialName: String ) { val viewModel: CallLinkDetailsViewModel = viewModel { CallLinkDetailsViewModel(roomId) @@ -99,7 +100,7 @@ fun EditCallLinkNameScreen( val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope EditCallLinkNameScreen( - initialNameValue = viewModel.nameSnapshot, + initialNameValue = initialName, onSaveClick = { lifecycleScope.launch { viewModel.setName(it) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt deleted file mode 100644 index e1d0c5bf30..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.calls.links.details - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.remember -import androidx.core.os.bundleOf -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.service.webrtc.links.CallLinkRoomId -import org.thoughtcrime.securesms.util.viewModel - -class CallLinkDetailsActivity : FragmentActivity() { - - companion object { - private const val ARG_ROOM_ID = "room.id" - - fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent { - return Intent(context, CallLinkDetailsActivity::class.java) - .putExtra(ARG_ROOM_ID, callLinkRoomId) - } - } - - private val roomId: CallLinkRoomId - get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!! - - private val viewModel: CallLinkDetailsViewModel by viewModel { - CallLinkDetailsViewModel(roomId) - } - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - - super.onCreate(savedInstanceState) - - setContent { - SignalTheme { - CallLinkDetailsScreen( - roomId = roomId, - viewModel = viewModel, - router = remember { Router() } - ) - } - } - } - - 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) - } - } - } - - override fun exitDetailLocation() = finishAfterTransition() - } -} 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 2da4fb0064..36de10dd8c 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 @@ -113,7 +113,12 @@ class DefaultCallLinkDetailsCallback( } override fun onEditNameClicked() { - router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId())) + router.goToCallDetail( + MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName( + callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(), + currentName = viewModel.nameSnapshot + ) + ) } override fun onShareClicked() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt index 59c51685b4..c9ea7f4932 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt @@ -6,7 +6,6 @@ package org.thoughtcrime.securesms.calls.links.details import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy @@ -194,10 +193,4 @@ class CallLinkDetailsViewModel( private fun toastCouldNotDeleteCallLink() { _state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) } } - - class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index 2315ac4f49..4788ae64e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -9,13 +9,13 @@ import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar -import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.util.CommunicationActions import org.signal.core.ui.R as CoreUiR @@ -122,11 +122,10 @@ class CallLogContextMenu( iconRes = CoreUiR.drawable.symbol_info_24, title = fragment.getString(R.string.CallContextMenu__info) ) { - val intent = when { - peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId()) - else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds) + when { + peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId()) + else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)) } - fragment.startActivity(intent) } } @@ -154,6 +153,7 @@ class CallLogContextMenu( interface Callbacks { fun startSelection(call: CallLogRow) + fun goToCallLinkDetails(roomId: CallLinkRoomId) fun deleteCall(call: CallLogRow) } } 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 c3e1dd6701..fa2054f838 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 @@ -32,7 +32,6 @@ import org.signal.core.util.orNull import org.thoughtcrime.securesms.MainNavigator import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment -import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity import org.thoughtcrime.securesms.components.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate @@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.doAfterNextLayout @@ -242,9 +242,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal MainToolbarViewModel.Event.Search.Close -> { viewModel.setSearchQuery("") } + MainToolbarViewModel.Event.Search.Open -> { mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search) } + is MainToolbarViewModel.Event.Search.Query -> { viewModel.setSearchQuery(it.query.trim()) } @@ -325,7 +327,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal ) startActivity(intent) } else { - startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId())) + goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId()) } } @@ -375,6 +377,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal ) } } + CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext()) CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext()) CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext()) @@ -386,6 +389,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal viewModel.toggleSelected(call.id) } + override fun goToCallLinkDetails(roomId: CallLinkRoomId) { + mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId)) + } + override fun deleteCall(call: CallLogRow) { MaterialAlertDialogBuilder(requireContext()) .setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt index 798654d50d..9f607f6b26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.saveable +import org.thoughtcrime.securesms.main.MainDetailBackStack import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.recipients.RecipientId @@ -18,7 +19,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId * Controls the navigation stack used by the chats screen. */ @OptIn(SavedStateHandleSaveableApi::class) -class ChatsBackStack(savedStateHandle: SavedStateHandle) { +class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack { companion object { private const val KEY = "chats_back_stack" @@ -29,7 +30,7 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) { ) } - val entries: SnapshotStateList = savedStateHandle.saveable( + override val entries: SnapshotStateList = savedStateHandle.saveable( key = KEY, saver = saver ) { @@ -45,13 +46,10 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) { } } - val isEmpty: Boolean - get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty - /** * Pushes an entry onto the stack. */ - fun push(location: MainNavigationDetailLocation) { + override fun push(location: MainNavigationDetailLocation) { when (location) { is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit @@ -63,23 +61,4 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) { else -> entries.add(location) } } - - /** - * Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root. - */ - fun pop(): Boolean { - if (entries.size <= 1) return false - entries.removeAt(entries.lastIndex) - return true - } - - /** - * Resets the stack to its base empty state. - */ - fun reset() { - entries.removeAll { it !is MainNavigationDetailLocation.Empty } - if (entries.isEmpty()) { - entries.add(MainNavigationDetailLocation.Empty) - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt deleted file mode 100644 index 1313631826..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.main - -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalResources -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import org.signal.core.ui.rememberIsSplitPane -import org.thoughtcrime.securesms.MainNavigator -import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen -import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen -import org.thoughtcrime.securesms.serialization.JsonSerializableNavType -import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId -import kotlin.reflect.typeOf - -private val callLinkRoomIdType = typeOf() - -fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) { - composable { - if (LocalResources.current.rememberIsSplitPane()) { - EmptyDetailScreen() - } - } - - composable( - typeMap = mapOf( - callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer()) - ) - ) { - informNavigatorWeAreReady() - - val route = it.toRoute() - - CallLinkDetailsScreen(roomId = route.callLinkRoomId) - } - - composable( - typeMap = mapOf( - callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer()) - ) - ) { - informNavigatorWeAreReady() - - val route = it.toRoute() - val parent = navHostController.previousBackStackEntry ?: return@composable - - CompositionLocalProvider(LocalViewModelStoreOwner provides parent) { - EditCallLinkNameScreen(roomId = route.callLinkRoomId) - } - } -} - -@Composable -private fun informNavigatorWeAreReady() { - val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider - LaunchedEffect(navigator) { - navigator?.onFirstRender() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt new file mode 100644 index 0000000000..c2b8c96796 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import androidx.compose.runtime.snapshots.SnapshotStateList + +interface MainDetailBackStack { + val entries: SnapshotStateList + + val isEmpty: Boolean + get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty + + fun push(location: MainNavigationDetailLocation) + + /** + * Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root. + */ + fun pop(): Boolean { + if (entries.size <= 1) return false + entries.removeAt(entries.lastIndex) + return true + } + + /** + * Resets the stack to its base empty state. + */ + fun reset() { + entries.removeAll { it !is MainNavigationDetailLocation.Empty } + if (entries.isEmpty()) { + entries.add(MainNavigationDetailLocation.Empty) + } + } +} 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 b01f75a7ec..6484f9f26b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -114,7 +114,10 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey { @Parcelize sealed class CallLinks : Calls { @Serializable - data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() { + data class EditCallLinkName( + val callLinkRoomId: CallLinkRoomId, + val currentName: String = "" + ) : CallLinks() { @Transient @IgnoredOnParcel override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId) 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 6afc080e8b..aa7641035e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow @@ -35,6 +34,7 @@ import kotlinx.coroutines.rx3.asObservable import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.calls.CallsBackStack import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.chats.ChatsBackStack import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository @@ -86,6 +86,20 @@ class MainNavigationViewModel( val chatsBackStackEntries: SnapshotStateList get() = chatsBackStack.entries + private val callsBackStack: CallsBackStack = CallsBackStack(savedStateHandle) + val callsBackStackEntries: SnapshotStateList + get() = callsBackStack.entries + + private val currentTabBackStack: MainDetailBackStack? + get() { + val currentListLocation = internalMainNavigationState.value.currentListLocation + return when { + currentListLocation.isChatsTab -> chatsBackStack + currentListLocation == MainNavigationListLocation.CALLS -> callsBackStack + else -> null + } + } + private val internalDetailLocation = MutableSharedFlow() val detailLocation: SharedFlow = internalDetailLocation @@ -97,10 +111,10 @@ class MainNavigationViewModel( .combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) } .asObservable() - private val internalActiveCallId = MutableStateFlow(null) - val observableActiveCallId: Observable> = internalActiveCallId.map { Optional.ofNullable(it) }.combine(isFullScreenPane) { id, expanded -> - if (expanded) Optional.ofNullable(null) else id - }.asObservable() + val observableActiveCallId: Observable> = + snapshotFlow { callsBackStack.activeCallId } + .combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) } + .asObservable() private val internalMegaphone = MutableStateFlow(Megaphone.NONE) val megaphone: StateFlow = internalMegaphone @@ -153,12 +167,6 @@ class MainNavigationViewModel( performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state -> state.copy(storyFailure = hasFailedStories) } - - viewModelScope.launch { - internalDetailLocation.collect { location -> - updateActiveStateForLocation(location) - } - } } fun onPaneAnchorChanged(isFullScreenPane: Boolean) { @@ -173,7 +181,7 @@ class MainNavigationViewModel( this@MainNavigationViewModel.isSplitPane = isSplitPane if (!isSplitPane) { - if (chatsBackStack.isEmpty) { + if (currentTabBackStack?.isEmpty == true) { lockPaneToSecondary = true setFocusedPane(ThreePaneScaffoldRole.Secondary) } @@ -204,9 +212,8 @@ class MainNavigationViewModel( setFocusedPane(role) } - earlyNavigationDetailLocationRequested?.let { detail -> + earlyNavigationDetailLocationRequested?.let { lockPaneToSecondary = false - updateActiveStateForLocation(detail) } return this.navigator!! @@ -246,20 +253,6 @@ class MainNavigationViewModel( */ override fun goTo(location: MainNavigationDetailLocation) = setDetailLocation(location) - private fun updateActiveStateForLocation(location: MainNavigationDetailLocation) { - when (location) { - is MainNavigationDetailLocation.CallLinkDetails -> { - internalActiveCallId.update { location.controllerKey } - } - - is MainNavigationDetailLocation.Calls -> { - internalActiveCallId.update { location.controllerKey } - } - - else -> Unit - } - } - private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation { val args = conversationArgs val liveRecipient = Recipient.live(args.recipientId) @@ -292,12 +285,13 @@ class MainNavigationViewModel( when (location) { is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> clearDetailLocation(chatsBackStack) + is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.CALLS -> clearDetailLocation(callsBackStack) is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location) is MainNavigationDetailLocation.Conversation -> goToConversation(location) + is MainNavigationDetailLocation.Calls, is MainNavigationDetailLocation.CallLinkDetails -> pushCallsDetailLocation(location) is MainNavigationDetailLocation.Empty, - is MainNavigationDetailLocation.CallLinkDetails, - is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> { + is MainNavigationDetailLocation.Stories -> { if (navigator == null) { earlyNavigationDetailLocationRequested = location return @@ -333,15 +327,29 @@ class MainNavigationViewModel( setFocusedPane(ThreePaneScaffoldRole.Primary) } - fun popChatsDetailLocation() { - chatsBackStack.pop() - if (chatsBackStack.isEmpty) { + fun popChatsDetailLocation() = popDetailLocation(chatsBackStack) + + private fun pushCallsDetailLocation(location: MainNavigationDetailLocation) { + if (location is MainNavigationDetailLocation.Calls && callsBackStack.activeCallId != location.controllerKey) { + callsBackStack.reset() + } + + callsBackStack.push(location) + setFocusedPane(ThreePaneScaffoldRole.Primary) + } + + fun popCallsDetailLocation() = popDetailLocation(callsBackStack) + + private fun popDetailLocation(backStack: MainDetailBackStack) { + backStack.pop() + + if (backStack.isEmpty) { lockPaneToSecondary = true popDetailPane() } } - private fun clearDetailLocation(backStack: ChatsBackStack) { + private fun clearDetailLocation(backStack: MainDetailBackStack) { backStack.reset() if (!isSplitPane) { lockPaneToSecondary = true