From 413962a093279d73186d07679cb50541eeeba681 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 7 May 2026 16:42:26 -0300 Subject: [PATCH] Bypass single-pane scaffold for RTL. Co-authored-by: Greyson Parrelli --- .../app/internal/InternalSettingsFragment.kt | 9 +++++ .../app/internal/InternalSettingsState.kt | 1 + .../app/internal/InternalSettingsViewModel.kt | 6 ++++ .../securesms/keyvalue/InternalValues.kt | 6 ++++ .../securesms/window/AppScaffold.kt | 36 +++++++++++++------ .../securesms/window/AppScaffoldAnimators.kt | 18 +++++++--- 6 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 80efbc827a..88f2b4aa42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -207,12 +207,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter switchPref( title = DSLSettingsText.from("Force split pane UI on phones."), + isEnabled = !state.forceSinglePane, isChecked = state.forceSplitPane, onClick = { viewModel.setForceSplitPane(!state.forceSplitPane) } ) + switchPref( + title = DSLSettingsText.from("Force single-pane on newer devices."), + isChecked = state.forceSinglePane, + onClick = { + viewModel.setForceSinglePane(!state.forceSinglePane) + } + ) + clickPref( title = DSLSettingsText.from("Display enable permission sheet"), onClick = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index ebc8990966..ef7a6f7e51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -31,6 +31,7 @@ data class InternalSettingsState( val hasPendingOneTimeDonation: Boolean, val hevcEncoding: Boolean, val forceSplitPane: Boolean, + val forceSinglePane: Boolean, val useNewMediaActivity: Boolean, val disableInternalUser: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 67027b8b84..123d83da7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -203,6 +203,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null, hevcEncoding = SignalStore.internal.hevcEncoding, forceSplitPane = SignalStore.internal.forceSplitPane, + forceSinglePane = SignalStore.internal.forceSinglePane, useNewMediaActivity = SignalStore.internal.useNewMediaActivity, disableInternalUser = RemoteConfig.internalUserDisabled ) @@ -225,6 +226,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + fun setForceSinglePane(forceSinglePane: Boolean) { + SignalStore.internal.forceSinglePane = forceSinglePane + refresh() + } + class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index b5f334809a..17925b558f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -32,6 +32,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats" const val ENCODE_HEVC: String = "internal.hevc_encoding" const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui" + const val FORCE_SINGLE_PANE_ON_ALL_DEVICES: String = "internal.force_single_pane_on_all_devices" const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint" const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup" const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info" @@ -48,6 +49,11 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal */ var forceSplitPane by booleanValue(FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE, false).falseForExternalUsers() + /** + * Force single-pane on all devices + */ + var forceSinglePane by booleanValue(FORCE_SINGLE_PANE_ON_ALL_DEVICES, false).falseForExternalUsers() + var useNewMediaActivity by booleanValue(USE_NEW_MEDIA_ACTIVITY, false).falseForExternalUsers() /** 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 6f8126644a..4282d47473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -49,9 +49,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLayoutDirection 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.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch @@ -61,6 +63,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.getWindowBreakpoint import org.signal.core.ui.isWidthExpanded import org.signal.core.ui.rememberIsSplitPane +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationRail @@ -132,8 +135,14 @@ fun AppScaffold( contentWindowInsets: WindowInsets = WindowInsets.systemBars, animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default ) { - val useSimpleScaffold = navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU - if (useSimpleScaffold) { + val isForceSinglePane = if (LocalInspectionMode.current) { + false + } else { + SignalStore.internal.forceSinglePane + } + + val useSimpleScaffold = isForceSinglePane || (navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < 33) + if (useSimpleScaffold && LocalLayoutDirection.current != LayoutDirection.Rtl) { SinglePaneAppScaffold( navigator = navigator, modifier = modifier, @@ -142,7 +151,8 @@ fun AppScaffold( secondaryContent = secondaryContent, bottomNavContent = bottomNavContent, snackbarHost = snackbarHost, - contentWindowInsets = contentWindowInsets + contentWindowInsets = contentWindowInsets, + animatorFactory = animatorFactory ) } else { AdaptiveAppScaffold( @@ -307,10 +317,14 @@ private fun SinglePaneAppScaffold( secondaryContent: @Composable () -> Unit, bottomNavContent: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, - contentWindowInsets: WindowInsets = WindowInsets.systemBars + contentWindowInsets: WindowInsets = WindowInsets.systemBars, + animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default ) { val showDetail = navigator.scaffoldValue.primary == PaneAdaptedValue.Expanded val coroutineScope = rememberCoroutineScope() + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val directionMultiplier = if (isRtl) -1 else 1 + val skipSlide = AppScaffoldNavigator.NavigationState.ENTER !in animatorFactory.enabledStates BackHandler(enabled = navigator.canNavigateBack()) { coroutineScope.launch { navigator.navigateBack() } @@ -326,12 +340,12 @@ private fun SinglePaneAppScaffold( 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 } + val transform = when { + skipSlide -> EnterTransition.None togetherWith ExitTransition.None + targetState -> slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth * directionMultiplier } togetherWith + slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth * directionMultiplier } + else -> slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth * directionMultiplier } togetherWith + slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth * directionMultiplier } } transform using SizeTransform(clip = false) { _, _ -> snap() } }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt index a614bfd8a9..dc7851259d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt @@ -19,7 +19,9 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp private const val SEEK_DAMPING_RATIO = Spring.DampingRatioNoBouncy @@ -76,9 +78,11 @@ fun ThreePaneScaffoldPaneScope.animateFloat( @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState { + val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1 + val offset = animateDp( targetWhenHiding = { - -AppScaffoldAnimationDefaults.InitAnimationOffset + -AppScaffoldAnimationDefaults.InitAnimationOffset * directionMultiplier }, targetWhenShowing = { 0.dp @@ -100,6 +104,8 @@ fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnima @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState { + val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1 + val scale = animateFloat( transitionSpec = { appScaffoldSeekSpring() @@ -112,7 +118,7 @@ fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnima transitionSpec = { appScaffoldSeekSpring() }, - targetWhenHiding = { -(88.dp) }, + targetWhenHiding = { -(88.dp) * directionMultiplier }, targetWhenShowing = { 0.dp } ) @@ -160,9 +166,11 @@ fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffol @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState { + val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1 + val offset = animateDp( targetWhenHiding = { - AppScaffoldAnimationDefaults.InitAnimationOffset + AppScaffoldAnimationDefaults.InitAnimationOffset * directionMultiplier }, targetWhenShowing = { 0.dp @@ -184,6 +192,8 @@ fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAni @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState { + val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1 + val scale = animateFloat( transitionSpec = { appScaffoldSeekSpring() @@ -197,7 +207,7 @@ fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAni appScaffoldSeekSpring() }, targetWhenShowing = { 0.dp }, - targetWhenHiding = { 88.dp } + targetWhenHiding = { 88.dp * directionMultiplier } ) val roundedCorners = animateDp(