Prevent multiple activity instances and fix strange launch behavior.

This commit is contained in:
Alex Hart
2025-05-02 12:36:01 -03:00
committed by Cody Henthorne
parent 04c14a82be
commit fabec719ab
3 changed files with 152 additions and 46 deletions

View File

@@ -1068,6 +1068,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout"
android:windowSoftInputMode="stateUnchanged"
android:resizeableActivity="true"
android:launchMode="singleTask"
android:exported="false"/>
<activity android:name=".pin.PinRestoreActivity"

View File

@@ -33,17 +33,19 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
@@ -216,7 +218,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
setContent {
val listHostState = rememberFragmentState()
val detailLocation by mainNavigationViewModel.detailLocationRequests.collectAsStateWithLifecycle()
val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle()
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
@@ -242,37 +243,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData()
MainContainer {
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
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<Any> {
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
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()

View File

@@ -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>(MainNavigationDetailLocation.Empty)
val detailLocationRequests: StateFlow<MainNavigationDetailLocation> = detailLocationRequestFlow
private var navigator: LegacyNavigator? = null
/**
* The latest detail location that has been requested, for consumption by other components.
*/
private val detailLocationFlow = MutableStateFlow<MainNavigationDetailLocation>(MainNavigationDetailLocation.Empty)
val detailLocation: StateFlow<MainNavigationDetailLocation> = detailLocationFlow
val detailLocationObservable: Observable<MainNavigationDetailLocation> = detailLocationFlow.asObservable()
private val internalDetailLocation = MutableStateFlow(initialDetailLocation)
val detailLocation: StateFlow<MainNavigationDetailLocation> = internalDetailLocation
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = 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<Any>, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): ThreePaneScaffoldNavigator<Any> {
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<Any>,
private val goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit
) : ThreePaneScaffoldNavigator<Any> 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)
}
}
}
}