diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt index 38cfadf864..3622ae9afe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt @@ -21,6 +21,8 @@ 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 { EmptyDetailScreen() @@ -28,7 +30,7 @@ fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) { composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer()) ) ) { informNavigatorWeAreReady() @@ -40,7 +42,7 @@ fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) { composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer()) ) ) { informNavigatorWeAreReady() diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt index 2ae5379750..708636532c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -62,6 +62,10 @@ import org.thoughtcrime.securesms.window.AppScaffoldAnimationState import kotlin.reflect.typeOf import kotlin.time.Duration.Companion.milliseconds +private val conversationArgsType = typeOf() +private val recipientIdType = typeOf() +private val messageIdType = typeOf() + fun NavGraphBuilder.chatNavGraphBuilder( chatNavGraphState: ChatNavGraphState ) { @@ -71,7 +75,7 @@ fun NavGraphBuilder.chatNavGraphBuilder( composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(ConversationArgs.serializer()) + conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer()) ) ) { navBackStackEntry -> val route = navBackStackEntry.toRoute() @@ -147,8 +151,8 @@ fun NavGraphBuilder.chatNavGraphBuilder( composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(RecipientId.serializer()), - typeOf() to MessageId.NavType() + recipientIdType to JsonSerializableNavType(RecipientId.serializer()), + messageIdType to MessageId.NavType() ) ) { navBackStackEntry -> val context = LocalContext.current @@ -173,7 +177,7 @@ fun NavGraphBuilder.chatNavGraphBuilder( composable( typeMap = mapOf( - typeOf() to JsonSerializableNavType(RecipientId.serializer()) + recipientIdType to JsonSerializableNavType(RecipientId.serializer()) ) ) { navBackStackEntry -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index f0b6c131a4..6f8126644a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -5,8 +5,16 @@ package org.thoughtcrime.securesms.window +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.snap +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -24,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneExpansionState import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics @@ -31,6 +40,7 @@ import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -41,13 +51,14 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import kotlinx.coroutines.launch import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.getWindowBreakpoint -import org.signal.core.ui.isSplitPane import org.signal.core.ui.isWidthExpanded import org.signal.core.ui.rememberIsSplitPane import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback @@ -87,6 +98,11 @@ enum class NavigationType { * A top-level scaffold that automatically adapts its layout based on the device's window size class. It is a generic container designed to handle the * arrangement of navigation rails, top/bottom bars, and list-detail pane management for both compact and large screens. * + * On phone-class layouts (single horizontal partition) running on devices that predate predictive back (API < 33), + * this dispatches to [SinglePaneAppScaffold], which skips [NavigableListDetailPaneScaffold] / [ThreePaneScaffold] and + * its lookahead measurement pass. The scaffold's seek-driven predictive back animation never fires on those devices, + * so we pay no UX cost for the simpler implementation. + * * @param topBarContent An optional top bar that spans across all panes. * * @param primaryContent The main content, which is typically the detail view in a split-pane layout. @@ -95,10 +111,10 @@ enum class NavigationType { * @param navRailContent The side navigation rail, shown on medium and larger screen sizes. * @param bottomNavContent The bottom navigation bar, shown on compact screen sizes. * - * @param paneExpansionState Manages the position and expansion of the panes in a list-detail layout. - * @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout. + * @param paneExpansionState Manages the position and expansion of the panes in a list-detail layout. Ignored by [SinglePaneAppScaffold]. + * @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout. Ignored by [SinglePaneAppScaffold]. * - * @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation. + * @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation. Ignored by [SinglePaneAppScaffold]. */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -115,6 +131,52 @@ fun AppScaffold( snackbarHost: @Composable () -> Unit = {}, contentWindowInsets: WindowInsets = WindowInsets.systemBars, animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default +) { + val useSimpleScaffold = navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + if (useSimpleScaffold) { + SinglePaneAppScaffold( + navigator = navigator, + modifier = modifier, + topBarContent = topBarContent, + primaryContent = primaryContent, + secondaryContent = secondaryContent, + bottomNavContent = bottomNavContent, + snackbarHost = snackbarHost, + contentWindowInsets = contentWindowInsets + ) + } else { + AdaptiveAppScaffold( + navigator = navigator, + modifier = modifier, + topBarContent = topBarContent, + primaryContent = primaryContent, + secondaryContent = secondaryContent, + navRailContent = navRailContent, + bottomNavContent = bottomNavContent, + paneExpansionState = paneExpansionState, + paneExpansionDragHandle = paneExpansionDragHandle, + snackbarHost = snackbarHost, + contentWindowInsets = contentWindowInsets, + animatorFactory = animatorFactory + ) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun AdaptiveAppScaffold( + navigator: AppScaffoldNavigator, + modifier: Modifier = Modifier, + topBarContent: @Composable () -> Unit = {}, + primaryContent: @Composable () -> Unit = {}, + secondaryContent: @Composable () -> Unit, + navRailContent: @Composable () -> Unit = {}, + bottomNavContent: @Composable () -> Unit = {}, + paneExpansionState: PaneExpansionState = rememberPaneExpansionState(), + paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, + snackbarHost: @Composable () -> Unit = {}, + contentWindowInsets: WindowInsets = WindowInsets.systemBars, + animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default ) { val minPaneWidth = navigator.scaffoldDirective.defaultPanePreferredWidth val navigationState = navigator.state @@ -229,6 +291,67 @@ fun AppScaffold( } } +/** + * Phone-only scaffold that swaps content between [secondaryContent] and [primaryContent] without using + * [NavigableListDetailPaneScaffold]. Avoids the lookahead measurement pass and deep adaptive layout tree + * that drives ANR on low-end devices. + * + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun SinglePaneAppScaffold( + navigator: AppScaffoldNavigator, + modifier: Modifier = Modifier, + topBarContent: @Composable () -> Unit = {}, + primaryContent: @Composable () -> Unit = {}, + secondaryContent: @Composable () -> Unit, + bottomNavContent: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + contentWindowInsets: WindowInsets = WindowInsets.systemBars +) { + val showDetail = navigator.scaffoldValue.primary == PaneAdaptedValue.Expanded + val coroutineScope = rememberCoroutineScope() + + BackHandler(enabled = navigator.canNavigateBack()) { + coroutineScope.launch { navigator.navigateBack() } + } + + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = contentWindowInsets, + topBar = topBarContent, + snackbarHost = snackbarHost, + modifier = modifier + ) { paddingValues -> + AnimatedContent( + targetState = showDetail, + transitionSpec = { + val transform = if (targetState) { + slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth } togetherWith + slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth } + } else { + slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth } togetherWith + slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth } + } + transform using SizeTransform(clip = false) { _, _ -> snap() } + }, + modifier = Modifier.padding(paddingValues), + label = "SimpleAppScaffold" + ) { isDetail -> + if (isDetail) { + primaryContent() + } else { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) { + secondaryContent() + } + bottomNavContent() + } + } + } + } +} + @Composable private fun ListAndNavigation( topBarContent: @Composable () -> Unit,