diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index a243cdd56d..a7ac488471 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -12,7 +12,15 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewTreeObserver +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getSerializableCompat @@ -29,11 +37,14 @@ import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSh import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.main.MainActivityListHostFragment +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor import org.thoughtcrime.securesms.notifications.VitalsViewModel import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.tabs.ConversationListTab import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository +import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsFragment import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel import org.thoughtcrime.securesms.util.AppStartup import org.thoughtcrime.securesms.util.CachedInflater @@ -42,6 +53,7 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.SplashScreenUtil import org.thoughtcrime.securesms.util.WindowUtil import org.thoughtcrime.securesms.util.viewModel +import org.thoughtcrime.securesms.window.AppScaffold class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider { @@ -85,7 +97,39 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner AppStartup.getInstance().onCriticalRenderEventStart() super.onCreate(savedInstanceState, ready) - setContentView(R.layout.main_activity) + setContent { + val navState = rememberFragmentState() + val listHostState = rememberFragmentState() + val detailLocation by navigator.viewModel.detailLocation.collectAsStateWithLifecycle() + + LaunchedEffect(detailLocation) { + if (detailLocation is MainNavigationDetailLocation.Conversation) { + startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent) + overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out) + } + } + + AppScaffold( + bottomNavContent = { + AndroidFragment( + clazz = ConversationListTabsFragment::class.java, + fragmentState = navState + ) + }, + navRailContent = { + AndroidFragment( + clazz = ConversationListTabsFragment::class.java, + fragmentState = navState + ) + } + ) { + AndroidFragment( + clazz = MainActivityListHostFragment::class.java, + fragmentState = listHostState, + modifier = Modifier.fillMaxSize() + ) + } + } val content: View = findViewById(android.R.id.content) content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java index 15d74f3500..ce040fc9b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -3,15 +3,20 @@ package org.thoughtcrime.securesms; import android.app.Activity; import android.content.Intent; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.internal.ViewModelProviders; import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation; +import org.thoughtcrime.securesms.main.MainNavigationViewModel; import org.thoughtcrime.securesms.recipients.RecipientId; import io.reactivex.rxjava3.disposables.Disposable; @@ -20,8 +25,10 @@ public class MainNavigator { public static final int REQUEST_CONFIG_CHANGES = 901; - private final AppCompatActivity activity; - private final LifecycleDisposable lifecycleDisposable; + private final AppCompatActivity activity; + private final LifecycleDisposable lifecycleDisposable; + + private MainNavigationViewModel viewModel; public MainNavigator(@NonNull AppCompatActivity activity) { this.activity = activity; @@ -30,6 +37,15 @@ public class MainNavigator { lifecycleDisposable.bindTo(activity); } + @MainThread + public @NonNull MainNavigationViewModel getViewModel() { + if (viewModel == null) { + viewModel = new ViewModelProvider(activity).get(MainNavigationViewModel.class); + } + + return viewModel; + } + public static MainNavigator get(@NonNull Activity activity) { if (!(activity instanceof MainActivity)) { throw new IllegalArgumentException("Activity must be an instance of MainActivity!"); @@ -57,10 +73,7 @@ public class MainNavigator { .map(builder -> builder.withDistributionType(distributionType) .withStartingPosition(startingPosition) .build()) - .subscribe(intent -> { - activity.startActivity(intent); - activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out); - }); + .subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent))); lifecycleDisposable.add(disposable); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt new file mode 100644 index 0000000000..3bf4e0fa63 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import android.content.Intent + +/** + * Describes which content to display in the detail view. + */ +sealed interface MainNavigationDetailLocation { + data object Empty : MainNavigationDetailLocation + data class Conversation(val intent: Intent) : MainNavigationDetailLocation +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt new file mode 100644 index 0000000000..09ec98ddbc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class MainNavigationViewModel : ViewModel() { + private val detailLocationFlow = MutableStateFlow(MainNavigationDetailLocation.Empty) + + val detailLocation: StateFlow = detailLocationFlow + + fun goTo(location: MainNavigationDetailLocation) { + detailLocationFlow.update { location } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt index a73be1a8bc..09b7bd0613 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt @@ -8,7 +8,10 @@ import androidx.fragment.app.viewModels import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationDestination +import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationState +import org.thoughtcrime.securesms.window.Navigation +import org.thoughtcrime.securesms.window.WindowSizeClass /** * Displays the "Chats" and "Stories" tab to a user. @@ -37,16 +40,28 @@ class ConversationListTabsFragment : ComposeFragment() { } if (state.visibilityState.isVisible()) { - MainNavigationBar( - state = navState, - onDestinationSelected = { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + val onDestinationSelected: (MainNavigationDestination) -> Unit = remember { + { when (it) { MainNavigationDestination.CHATS -> viewModel.onChatsSelected() MainNavigationDestination.CALLS -> viewModel.onCallsSelected() MainNavigationDestination.STORIES -> viewModel.onStoriesSelected() } } - ) + } + + if (windowSizeClass.navigation == Navigation.BAR) { + MainNavigationBar( + state = navState, + onDestinationSelected = onDestinationSelected + ) + } else { + MainNavigationRail( + state = navState, + onDestinationSelected = onDestinationSelected + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 8a81e5193c..8d0503baca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1121,5 +1121,12 @@ object RemoteConfig { hotSwappable = true ) + /** Whether to allow different WindowSizeClasses to be used to determine screen layout */ + val largeScreenUi: Boolean by remoteBoolean( + key = "android.largeScreenUI", + defaultValue = false, + hotSwappable = false + ) + // endregion } 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 331c553ff8..900a4f2afe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -30,6 +31,7 @@ import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationState +import org.thoughtcrime.securesms.util.RemoteConfig enum class Navigation { RAIL, @@ -39,6 +41,10 @@ enum class Navigation { /** * Describes the size of screen we are displaying, and what components should be displayed. * + * Screens should utilize this class by convention instead of calling [currentWindowAdaptiveInfo] + * themselves, as this class includes checks with [RemoteConfig] to ensure we're allowed to display + * content in different screen sizes. + * * https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes */ enum class WindowSizeClass( @@ -54,9 +60,20 @@ enum class WindowSizeClass( companion object { @Composable fun rememberWindowSizeClass(): WindowSizeClass { - val wsc = currentWindowAdaptiveInfo().windowSizeClass val orientation = LocalConfiguration.current.orientation + if (!LocalInspectionMode.current && !RemoteConfig.largeScreenUi) { + return when (orientation) { + Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { + COMPACT_PORTRAIT + } + Configuration.ORIENTATION_LANDSCAPE -> COMPACT_LANDSCAPE + else -> error("Unexpected orientation: $orientation") + } + } + + val wsc = currentWindowAdaptiveInfo().windowSizeClass + return remember(orientation, wsc) { when (orientation) { Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { @@ -88,10 +105,10 @@ enum class WindowSizeClass( */ @Composable fun AppScaffold( - listContent: @Composable () -> Unit, - detailContent: @Composable () -> Unit, - navRailContent: @Composable () -> Unit, - bottomNavContent: @Composable () -> Unit + detailContent: @Composable () -> Unit = {}, + navRailContent: @Composable () -> Unit = {}, + bottomNavContent: @Composable () -> Unit = {}, + listContent: @Composable () -> Unit ) { val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml deleted file mode 100644 index b2fabcca41..0000000000 --- a/app/src/main/res/layout/main_activity.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - \ No newline at end of file