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