From fabec719aba2b5b2b130fc5d5997d496bd52036e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 2 May 2025 12:36:01 -0300 Subject: [PATCH] Prevent multiple activity instances and fix strange launch behavior. --- app/src/main/AndroidManifest.xml | 1 + .../thoughtcrime/securesms/MainActivity.kt | 75 ++++++----- .../securesms/main/MainNavigationViewModel.kt | 122 ++++++++++++++++-- 3 files changed, 152 insertions(+), 46 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 21ca86a3eb..e8a6e3ca87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1068,6 +1068,7 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout" android:windowSoftInputMode="stateUnchanged" android:resizeableActivity="true" + android:launchMode="singleTask" android:exported="false"/> ( - scaffoldDirective = calculatePaneScaffoldDirective( - currentWindowAdaptiveInfo() - ).copy( - maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1, - horizontalPartitionSpacerSize = contentLayoutData.partitionWidth, - defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth) - ) - ) - - if (SignalStore.internal.largeScreenUi) { - LaunchedEffect(scaffoldNavigator.currentDestination) { - if (scaffoldNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) { - mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty) - } - } - } - - LaunchedEffect(detailLocation) { - if (detailLocation is MainNavigationDetailLocation.Conversation) { - if (SignalStore.internal.largeScreenUi) { - scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Primary, detailLocation) - } else { - startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent) - mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty) - } - } - } + val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth) AppScaffold( - navigator = scaffoldNavigator, + navigator = wrappedNavigator, bottomNavContent = { if (isNavigationVisible) { Column( @@ -338,7 +312,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } }, detailContent = { - when (val destination = scaffoldNavigator.currentDestination?.contentKey) { + when (val destination = wrappedNavigator.currentDestination?.contentKey) { is MainNavigationDetailLocation.Conversation -> { val fragmentState = key(destination) { rememberFragmentState() } AndroidFragment( @@ -384,6 +358,41 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner lifecycleDisposable += vitalsViewModel.vitalsState.subscribe(this::presentVitalsState) } + /** + * Creates and wraps a scaffold navigator such that we can use it to operate with both + * our split pane and legacy activities. + */ + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Composable + private fun rememberNavigator( + windowSizeClass: WindowSizeClass, + contentLayoutData: MainContentLayoutData, + maxWidth: Dp + ): ThreePaneScaffoldNavigator { + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator( + scaffoldDirective = calculatePaneScaffoldDirective( + currentWindowAdaptiveInfo() + ).copy( + maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1, + horizontalPartitionSpacerSize = contentLayoutData.partitionWidth, + defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth) + ) + ) + + val coroutine = rememberCoroutineScope() + + return remember(scaffoldNavigator, coroutine) { + mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation -> + when (detailLocation) { + is MainNavigationDetailLocation.Conversation -> { + startActivity(detailLocation.intent) + } + MainNavigationDetailLocation.Empty -> Unit + } + } + } + } + @Composable private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) { val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() 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 2995f45d37..ae34190634 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -5,9 +5,15 @@ package org.thoughtcrime.securesms.main +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +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.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -26,22 +32,21 @@ import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.stories.Stories -class MainNavigationViewModel(initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS) : ViewModel() { +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +class MainNavigationViewModel( + initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS, + initialDetailLocation: MainNavigationDetailLocation = MainNavigationDetailLocation.Empty +) : ViewModel() { private val megaphoneRepository = AppDependencies.megaphoneRepository - /** - * A shared flow of detail location requests that the MainActivity will service. - * This is immediately set back to empty after requesting a detail location to prevent duplicate launches. - */ - private val detailLocationRequestFlow = MutableStateFlow(MainNavigationDetailLocation.Empty) - val detailLocationRequests: StateFlow = detailLocationRequestFlow + private var navigator: LegacyNavigator? = null /** * The latest detail location that has been requested, for consumption by other components. */ - private val detailLocationFlow = MutableStateFlow(MainNavigationDetailLocation.Empty) - val detailLocation: StateFlow = detailLocationFlow - val detailLocationObservable: Observable = detailLocationFlow.asObservable() + private val internalDetailLocation = MutableStateFlow(initialDetailLocation) + val detailLocation: StateFlow = internalDetailLocation + val detailLocationObservable: Observable = internalDetailLocation.asObservable() private val internalMegaphone = MutableStateFlow(Megaphone.NONE) val megaphone: StateFlow = internalMegaphone @@ -81,10 +86,40 @@ class MainNavigationViewModel(initialListLocation: MainNavigationListLocation = } } + /** + * 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. + */ + fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): ThreePaneScaffoldNavigator { + val previous = this.navigator + val wrapped = LegacyNavigator(composeScope, threePaneScaffoldNavigator, goToLegacyDetailLocation) + this.navigator = wrapped + + if (previous != null) { + val destination = previous.currentDestination?.contentKey ?: return wrapped + if (destination is MainNavigationDetailLocation) { + goTo(destination) + } + } else { + goTo(internalDetailLocation.value) + } + + return wrapped + } + + /** + * Navigates to the requested location. If the navigator is not present, this functionally sets our + * "default" location to that specified, and we will route the user there when the navigator is set. + */ fun goTo(location: MainNavigationDetailLocation) { - viewModelScope.launch { - detailLocationRequestFlow.emit(location) - detailLocationFlow.emit(location) + if (navigator == null) { + internalDetailLocation.update { + location + } + } + + navigator?.composeScope?.launch { + navigator?.navigateTo(ThreePaneScaffoldRole.Primary, location) } } @@ -158,4 +193,65 @@ class MainNavigationViewModel(initialListLocation: MainNavigationListLocation = enum class NavigationEvent { STORY_CAMERA_FIRST } + + /** + * ScaffoldNavigator wrapper that delegates to a default implementation + * Ensures we properly update `internalDetailLocation` as the user moves between + * screens. + * + * Delegates to a legacy method if the user is not a large-screen-ui enabled user. + */ + @Stable + private inner class LegacyNavigator( + val composeScope: CoroutineScope, + private val delegate: ThreePaneScaffoldNavigator, + private val goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit + ) : ThreePaneScaffoldNavigator by delegate { + + /** + * Due to some weirdness with `navigateBack`, we don't seem to be able to execute + * code after running the delegate method. So instead, we mark that we saw the call + * and then handle updates in `seekBack`. + */ + private var didNavigateBack: Boolean = false + + /** + * If we're not a large screen user, this delegates to the legacy method. + * Otherwise, we will delegate to the delegate, and update our detail location. + */ + override suspend fun navigateTo(pane: ThreePaneScaffoldRole, contentKey: Any?) { + if (!SignalStore.internal.largeScreenUi && contentKey is MainNavigationDetailLocation) { + goToLegacyDetailLocation(contentKey) + } else if (contentKey is MainNavigationDetailLocation.Conversation) { + delegate.navigateTo(pane, contentKey) + } + + if (SignalStore.internal.largeScreenUi && contentKey is MainNavigationDetailLocation) { + internalDetailLocation.emit(contentKey) + } + } + + /** + * Marks the back, and delegates to the delegate. + */ + override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean { + didNavigateBack = true + return delegate.navigateBack(backNavigationBehavior) + } + + /** + * Delegates to the delegate, and then consumes the back. If back is consumed, we will update + * the internal detail location. + */ + override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) { + delegate.seekBack(backNavigationBehavior, fraction) + + if (didNavigateBack) { + didNavigateBack = false + + val destination = currentDestination?.contentKey as? MainNavigationDetailLocation ?: MainNavigationDetailLocation.Empty + internalDetailLocation.emit(destination) + } + } + } }