Migrate chats tab to nav 3.

- resolves signalapp/Signal-Android#8947
- resolves signalapp/Signal-Android#14777
- resolves signalapp/Signal-Android#14784
- resolves signalapp/Signal-Android#14800
- resolves signalapp/Signal-Android#14803
This commit is contained in:
jeffrey-signal
2026-06-22 13:25:32 -04:00
parent 48901f64c7
commit cdb73d4b8a
11 changed files with 616 additions and 366 deletions
@@ -348,11 +348,13 @@ class MainNavigationLaunchTest {
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
await(description = "conversation cleared from chats back stack after Empty detail intent") {
vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation }
}
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
@@ -70,6 +70,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
@@ -83,6 +85,7 @@ 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.navigation.TransitionSpecs
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
@@ -103,6 +106,8 @@ import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.calls.quality.CallQuality
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.chats.ConversationTransitionState
import org.thoughtcrime.securesms.chats.chatsNavEntries
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
@@ -132,7 +137,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.ChatNavGraphState
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
@@ -141,7 +145,6 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationRouter
@@ -155,7 +158,6 @@ import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
@@ -494,18 +496,15 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation,
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder(chatNavGraphState)
LaunchedEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
}
LaunchedEffect(isSplitPane) {
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val callsNavHostController = rememberDetailNavHostController(
@@ -527,22 +526,23 @@ class MainActivity :
}
LaunchedEffect(Unit) {
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
@@ -556,6 +556,7 @@ class MainActivity :
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
@@ -606,6 +607,14 @@ class MainActivity :
}
}
LaunchedEffect(wrappedNavigator.scaffoldValue.primary) {
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Hidden &&
mainNavigationState.currentListLocation.isChatsTab
) {
mainNavigationViewModel.onChatsDetailPaneCollapsed()
}
}
val noEnterTransitionFactory = remember {
AppScaffoldAnimationStateFactory(
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
@@ -616,7 +625,7 @@ class MainActivity :
AppScaffold(
navigator = wrappedNavigator,
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
modifier = convoTransitionState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
snackbarHost = {
@@ -727,10 +736,16 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
)
if (mainNavigationViewModel.chatsBackStackEntries.isNotEmpty()) {
NavDisplay(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
}
MainNavigationListLocation.CALLS -> {
@@ -758,7 +773,7 @@ class MainActivity :
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
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.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
companion object {
private const val KEY = "chats_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf()
}
val activeConversationThreadId: Long?
get() = entries
.filterIsInstance<MainNavigationDetailLocation.Conversation>()
.lastOrNull()
?.controllerKey
val hasConversation: Boolean
get() = entries.any { it is MainNavigationDetailLocation.Conversation }
/**
* Pushes an entry onto the stack.
*/
fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
is MainNavigationDetailLocation.Conversation -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
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(isSplitPane: Boolean) {
entries.clear()
if (isSplitPane) {
entries.add(MainNavigationDetailLocation.Empty)
}
}
/**
* Ensures that [MainNavigationDetailLocation.Empty] is present in the stack iff isSplitPane=true.
*/
fun updateEmptyDetailForPaneMode(isSplitPane: Boolean) {
val hasEmptyBase = entries.firstOrNull() is MainNavigationDetailLocation.Empty
when {
isSplitPane && !hasEmptyBase -> entries.add(0, MainNavigationDetailLocation.Empty)
!isSplitPane && hasEmptyBase -> entries.removeAll { it is MainNavigationDetailLocation.Empty }
}
}
}
@@ -0,0 +1,161 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
fun EntryProviderScope<NavKey>.chatsNavEntries(
transitionState: ConversationTransitionState
) {
entry<MainNavigationDetailLocation.Empty> {
NoConvoSelectedEntry()
}
entry<MainNavigationDetailLocation.Conversation>(
// disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode.
metadata = TransitionSpecs.None.metadata
) { route ->
ConversationEntry(route, transitionState)
}
entry<MainNavigationDetailLocation.Chats.MessageDetails> { route ->
MessageDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Chats.ConversationSettings> { route ->
ConversationSettingsEntry(route)
}
}
@Composable
private fun NoConvoSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun ConversationEntry(
route: MainNavigationDetailLocation.Conversation,
transitionState: ConversationTransitionState
) {
val context = LocalContext.current
val navigatorProvider = context as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) {
"Handed null Conversation intent arguments."
}
val fragmentContentReady = remember { MutableStateFlow(false) }
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
ConversationLoadingMask(
transitionState = transitionState,
contentReady = fragmentContentReady,
onFirstRender = { navigatorProvider?.onFirstRender() }
) { modifier ->
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = arguments,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) { fragment ->
backPressedState.attach(fragment)
fragment.viewLifecycleOwner.lifecycleScope.launch {
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
fragment.didFirstFrameRender.collectLatest { fragmentContentReady.value = it }
}
}
}
}
}
@Composable
private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
AndroidFragment(
clazz = MessageDetailsFragment::class.java,
fragmentState = fragmentState,
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
)
}
@Composable
private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments: Bundle? by produceState(null, route.recipientId) {
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
}
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
arguments?.let { args ->
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
AndroidFragment(
clazz = ConversationSettingsNavHostFragment::class.java,
fragmentState = fragmentState,
arguments = args,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
) { fragment ->
backPressedState.attach(fragment)
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import kotlin.time.Duration.Companion.seconds
/**
* Wraps [content] with an animation that crossfades a snapshotted chat list bitmap over the conversation fragment while it loads its first frame.
*
* @param contentReady emits when the fragment's first frame has been rendered.
* @param onFirstRender signals that this composable has content ready to display, so the parent activity can proceed with its first draw.
* @param content will be animated in as the overlay fades out.
*/
@Composable
fun ConversationLoadingMask(
transitionState: ConversationTransitionState,
contentReady: StateFlow<Boolean>,
onFirstRender: () -> Unit,
content: @Composable (chatModifier: Modifier) -> Unit
) {
// it can take a long time to load content, so we use a "fake" chat list image to delay displaying the fragment
// and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
var shouldDisplayFragment by remember {
val hasBitmap = transitionState.chatBitmap != null
mutableStateOf(!hasBitmap)
}
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
val bitmap = transitionState.chatBitmap
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
val chatAnimationState = transition.chatAnimationState(hasFake = bitmap != null)
LaunchedEffect(transition.currentState, transition.isRunning) {
if (transition.currentState && !transition.isRunning) {
transitionState.clearBitmap()
}
}
LaunchedEffect(shouldDisplayFragment) {
onFirstRender()
}
LaunchedEffect(contentReady) {
if (!shouldDisplayFragment) {
withTimeoutOrNull(5.seconds) {
contentReady.first { it }
}
shouldDisplayFragment = true
}
}
val chatModifier = Modifier.graphicsLayer {
with(chatAnimationState) { applyChildValues() }
}
Box(modifier = Modifier.fillMaxSize()) {
content(chatModifier)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
with(fakeChatListAnimationState) { applyChildValues() }
}
.fillMaxSize()
)
}
}
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
return if (!hasFake) {
remember {
AppScaffoldAnimationState(
offset = mutableStateOf(0.dp),
alpha = alpha
)
}
} else {
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
/**
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
* in lieu of proper support for postponing enter transitions.
*/
@Stable
class ConversationTransitionState private constructor(
val isSplitPane: Boolean,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(isSplitPane: Boolean): ConversationTransitionState {
val graphicsLayer = rememberGraphicsLayer()
return remember(isSplitPane) {
ConversationTransitionState(isSplitPane, graphicsLayer)
}
}
}
var chatBitmap: ImageBitmap? by mutableStateOf(null)
private set
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
suspend fun writeGraphicsLayerToBitmap() {
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
fun writeContentToGraphicsLayer(): Modifier {
if (isSplitPane) return Modifier
return Modifier.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
hasWrittenToGraphicsLayer = true
}
drawLayer(graphicsLayer)
}
}
fun clearBitmap() {
chatBitmap = null
}
}
@@ -1,304 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.os.Build
import android.os.Bundle
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.dp
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import kotlin.reflect.typeOf
import kotlin.time.Duration.Companion.milliseconds
private val conversationArgsType = typeOf<ConversationArgs>()
private val recipientIdType = typeOf<RecipientId>()
private val messageIdType = typeOf<MessageId>()
fun NavGraphBuilder.chatNavGraphBuilder(
chatNavGraphState: ChatNavGraphState
) {
composable<MainNavigationDetailLocation.Empty> {
if (LocalResources.current.rememberIsSplitPane()) {
EmptyDetailScreen()
}
}
composable<MainNavigationDetailLocation.Conversation>(
typeMap = mapOf(
conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer())
)
) { navBackStackEntry ->
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Conversation>()
val fragmentState = key(route) { rememberFragmentState() }
val context = LocalContext.current
// Because it can take a long time to load content, we use a "fake" chat list image to delay displaying
// the fragment and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
var shouldDisplayFragment by remember { mutableStateOf(chatNavGraphState.chatBitmap == null) }
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
val bitmap = chatNavGraphState.chatBitmap
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
val chatAnimationState = transition.chatAnimationState(bitmap != null)
LaunchedEffect(transition.currentState, transition.isRunning) {
if (transition.currentState && !transition.isRunning) {
chatNavGraphState.clearBitmap()
}
}
LaunchedEffect(shouldDisplayFragment) {
(context as? MainNavigator.NavigatorProvider)?.onFirstRender()
}
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
with(fakeChatListAnimationState) {
applyChildValues()
}
}
.fillMaxSize()
)
}
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.graphicsLayer {
with(chatAnimationState) {
applyChildValues()
}
}
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) { fragment ->
backPressedState.attach(fragment)
fragment.viewLifecycleOwner.lifecycleScope.launch {
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
fragment.didFirstFrameRender.collectLatest {
if (!shouldDisplayFragment) {
shouldDisplayFragment = it
if (!it) {
delay(150.milliseconds)
shouldDisplayFragment = true
}
}
}
}
}
}
}
composable<MainNavigationDetailLocation.Chats.MessageDetails>(
typeMap = mapOf(
recipientIdType to JsonSerializableNavType(RecipientId.serializer()),
messageIdType to MessageId.NavType()
)
) { navBackStackEntry ->
val context = LocalContext.current
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.MessageDetails>()
val fragmentState = key(route) { rememberFragmentState() }
LaunchedEffect(Unit) {
(context as? MainNavigator.NavigatorProvider)?.onFirstRender()
}
AndroidFragment(
clazz = MessageDetailsFragment::class.java,
fragmentState = fragmentState,
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
)
}
composable<MainNavigationDetailLocation.Chats.ConversationSettings>(
typeMap = mapOf(
recipientIdType to JsonSerializableNavType(RecipientId.serializer())
)
) { navBackStackEntry ->
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.ConversationSettings>()
val fragmentState = key(route) { rememberFragmentState() }
val arguments: Bundle? by produceState(null, route.recipientId) {
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
}
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
arguments?.let { args ->
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
AndroidFragment(
clazz = ConversationSettingsNavHostFragment::class.java,
fragmentState = fragmentState,
arguments = args,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
) { fragment ->
backPressedState.attach(fragment)
}
}
}
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
return if (!hasFake) {
remember {
AppScaffoldAnimationState(
offset = mutableStateOf(0.dp),
alpha = alpha
)
}
} else {
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
}
/**
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
* in lieu of proper support for postponing enter transitions.
*/
@Stable
class ChatNavGraphState private constructor(
val isSplitPane: Boolean,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(isSplitPane: Boolean): ChatNavGraphState {
val graphicsLayer = rememberGraphicsLayer()
return remember(isSplitPane) {
ChatNavGraphState(
isSplitPane,
graphicsLayer
)
}
}
}
var chatBitmap: ImageBitmap? by mutableStateOf(null)
private set
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
suspend fun writeGraphicsLayerToBitmap() {
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
fun writeContentToGraphicsLayer(): Modifier {
return Modifier.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
hasWrittenToGraphicsLayer = true
}
drawLayer(graphicsLayer)
}
}
fun clearBitmap() {
chatBitmap = null
}
}
@@ -81,7 +81,10 @@ enum class MainNavigationListLocation(
STORIES(
label = R.string.ConversationListTabs__stories,
icon = R.raw.stories_28
)
);
val isChatsTab: Boolean
get() = this == CHATS || this == ARCHIVE
}
data class MainNavigationState(
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.main
import android.os.Parcelable
import androidx.compose.runtime.saveable.SaverScope
import androidx.navigation3.runtime.NavKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -23,7 +24,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
*/
@Serializable
@Parcelize
sealed interface MainNavigationDetailLocation : Parcelable {
sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
class Saver(
val earlyLocation: MainNavigationDetailLocation?
@@ -6,9 +6,12 @@
package org.thoughtcrime.securesms.main
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -34,6 +37,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.chats.ChatsBackStack
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -46,6 +50,7 @@ import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.delegate
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class MainNavigationViewModel(
@@ -74,16 +79,23 @@ class MainNavigationViewModel(
private var navigator: AppScaffoldNavigator<Any>? = null
private var navigatorScope: CoroutineScope? = null
private var captureChatListSnapshot: (suspend () -> Unit)? = null
private var isSplitPane: Boolean = false
private val chatsBackStack: ChatsBackStack = ChatsBackStack(savedStateHandle)
val chatsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = chatsBackStack.entries
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
private val internalIsFullScreenPane = MutableStateFlow(false)
val isFullScreenPane: StateFlow<Boolean> = internalIsFullScreenPane
private val internalActiveChatThreadId = MutableStateFlow(-1L)
val observableActiveChatThreadId: Observable<Long> = internalActiveChatThreadId.combine(isFullScreenPane) { id, expanded ->
if (expanded) -1L else id
}.asObservable()
val observableActiveChatThreadId: Observable<Long> =
snapshotFlow { chatsBackStack.activeConversationThreadId ?: -1L }
.combine(isFullScreenPane) { id, expanded -> if (expanded) -1L else id }
.asObservable()
private val internalActiveCallId = MutableStateFlow<CallLogRow.Id?>(null)
val observableActiveCallId: Observable<Optional<out CallLogRow.Id>> = internalActiveCallId.map { Optional.ofNullable(it) }.combine(isFullScreenPane) { id, expanded ->
@@ -153,6 +165,24 @@ class MainNavigationViewModel(
internalIsFullScreenPane.update { isFullScreenPane }
}
fun setChatListSnapshotCaptureProvider(capture: suspend () -> Unit) {
captureChatListSnapshot = capture
}
fun onSplitPaneChanged(isSplitPane: Boolean) {
this@MainNavigationViewModel.isSplitPane = isSplitPane
chatsBackStack.updateEmptyDetailForPaneMode(isSplitPane)
// if no conversation is selected, clear the empty detail pane when switching from split pane to single pane mode.
if (!isSplitPane &&
internalMainNavigationState.value.currentListLocation.isChatsTab &&
!chatsBackStack.hasConversation &&
navigator?.scaffoldValue?.primary == PaneAdaptedValue.Expanded
) {
navigatorScope?.launch { navigator?.navigateBack() }
}
}
/**
* Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
@@ -161,18 +191,22 @@ class MainNavigationViewModel(
this.navigatorScope = composeScope
this.navigator = Nav(threePaneScaffoldNavigator)
val pendingFocus = earlyFocusedPaneRequested
earlyFocusedPaneRequested = null
earlyNavigationListLocationRequested?.let {
goTo(it)
}
earlyNavigationListLocationRequested = null
earlyFocusedPaneRequested?.let {
setFocusedPane(it)
pendingFocus?.let { role ->
if (role == ThreePaneScaffoldRole.Primary) {
lockPaneToSecondary = false
}
setFocusedPane(role)
}
earlyFocusedPaneRequested = null
earlyNavigationDetailLocationRequested?.let { detail ->
lockPaneToSecondary = false
updateActiveStateForLocation(detail)
@@ -213,24 +247,10 @@ class MainNavigationViewModel(
* This does not update what panel is currently focused, so that we can perform actions (such as first
* render) *before* swapping panes. This helps to prevent flashing / duplicate loads.
*/
override fun goTo(location: MainNavigationDetailLocation) {
when (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: MainNavigationDetailLocation) = setDetailLocation(location)
private fun updateActiveStateForLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Conversation -> {
internalActiveChatThreadId.update { location.controllerKey }
}
is MainNavigationDetailLocation.CallLinkDetails -> {
internalActiveCallId.update { location.controllerKey }
}
@@ -243,14 +263,14 @@ class MainNavigationViewModel(
}
}
private fun goToConversation(location: MainNavigationDetailLocation.Conversation) = viewModelScope.launch {
val args = location.conversationArgs
private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation {
val args = conversationArgs
val liveRecipient = Recipient.live(args.recipientId)
val recipientSnapshot = liveRecipient.get()
val wallpaper = recipientSnapshot.wallpaper
val updatedArgs = if (recipientSnapshot.isResolving || (wallpaper?.isPhoto == true && !wallpaper.isPrefetched)) {
withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS) {
withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS.milliseconds) {
withContext(Dispatchers.Default) {
val freshWallpaper = liveRecipient.resolve().wallpaper
if (freshWallpaper?.prefetch(AppDependencies.application, NAV_PREFETCH_TIMEOUT_MS) == false) {
@@ -266,19 +286,68 @@ class MainNavigationViewModel(
args.copy(hasWallpaper = wallpaper != null)
}
setDetailLocation(MainNavigationDetailLocation.Conversation(updatedArgs))
return copy(conversationArgs = updatedArgs)
}
private fun setDetailLocation(location: MainNavigationDetailLocation) {
lockPaneToSecondary = false
val currentListLocation = internalMainNavigationState.value.currentListLocation
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
when (location) {
is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> chatsBackStack.reset(isSplitPane)
is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location)
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
is MainNavigationDetailLocation.Empty,
is MainNavigationDetailLocation.CallLinkDetails,
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
if (navigator == null) {
earlyNavigationDetailLocationRequested = location
return
}
viewModelScope.launch {
internalDetailLocation.emit(location)
}
}
}
}
viewModelScope.launch {
internalDetailLocation.emit(location)
private fun goToConversation(location: MainNavigationDetailLocation.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
pushChatsDetailLocation(location)
} else {
viewModelScope.launch {
captureSnapshot()
pushChatsDetailLocation(location.withPreloadedWallpaper())
}
}
}
private fun pushChatsDetailLocation(location: MainNavigationDetailLocation) {
chatsBackStack.push(location)
updateActiveStateForLocation(location)
setFocusedPane(ThreePaneScaffoldRole.Primary)
}
/**
* Inverse of [pushChatsDetailLocation]. Pops the top chats detail entry and, if no conversation
* remains, records the user's intent to stay on the list pane (so a subsequent config change does
* not errantly restore them to the Primary/detail pane).
*/
fun popChatsDetailLocation() {
chatsBackStack.pop()
if (!chatsBackStack.hasConversation) {
lockPaneToSecondary = true
}
}
fun onChatsDetailPaneCollapsed() {
if (!chatsBackStack.hasConversation) {
chatsBackStack.reset(isSplitPane)
}
}
@@ -7,6 +7,8 @@ package org.signal.core.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -17,6 +19,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.NavigationEvent
/**
@@ -106,4 +109,14 @@ object TransitionSpecs {
) + fadeOut(animationSpec = tween(DURATION))
}
}
/**
* No enter/exit animation.
*/
object None {
val metadata: Map<String, Any> =
NavDisplay.transitionSpec { EnterTransition.None togetherWith ExitTransition.None } +
NavDisplay.popTransitionSpec { EnterTransition.None togetherWith ExitTransition.None } +
NavDisplay.predictivePopTransitionSpec { EnterTransition.None togetherWith ExitTransition.None }
}
}