diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt index bddf5b661747..23ba2a7ede05 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -2,40 +2,32 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.navigation.NavController import androidx.navigation.NavHostController import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.navigation.navigate import com.ramcosta.composedestinations.navigation.popBackStack import com.ramcosta.composedestinations.rememberNavHostEngine import com.ramcosta.composedestinations.utils.destination -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.dropWhile -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.compose.NavGraphs -import net.mullvad.mullvadvpn.compose.appCurrentDestinationFlow import net.mullvad.mullvadvpn.compose.destinations.ChangelogDestination import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination -import net.mullvad.mullvadvpn.compose.destinations.Destination import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination -import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination -import net.mullvad.mullvadvpn.compose.destinations.SplashDestination import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel -import net.mullvad.mullvadvpn.viewmodel.ServiceConnectionViewModel -import net.mullvad.mullvadvpn.viewmodel.ServiceState +import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import org.koin.androidx.compose.koinViewModel private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) -private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -43,7 +35,12 @@ fun MullvadApp() { val engine = rememberNavHostEngine() val navController: NavHostController = engine.rememberNavController() - val serviceVm = koinViewModel() + val serviceVm = koinViewModel() + + DisposableEffect(Unit) { + navController.addOnDestinationChangedListener(serviceVm) + onDispose { navController.removeOnDestinationChangedListener(serviceVm) } + } DestinationsNavHost( modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), @@ -54,43 +51,13 @@ fun MullvadApp() { // Globally handle daemon dropped connection with NoDaemonScreen LaunchedEffect(Unit) { - combine( - serviceVm.uiState - // Wait for the first connected state - .dropWhile { it !is ServiceState.Connected }, - navController.appCurrentDestinationFlow, - ) { serviceState, destination -> - val backstackContainsNoDaemon = - navController.backStackContains(NoDaemonScreenDestination) - // If we are have NoDaemonScreen on backstack and received a connected state, pop it - if (backstackContainsNoDaemon && serviceState == ServiceState.Connected) { - DaemonNavigation.RemoveNoDaemonScreen - } - // If we are not connected to and expect to have a service connection, show - // NoDaemonScreen. - else if ( - backstackContainsNoDaemon || - destination.shouldHaveServiceConnection() && - serviceState == ServiceState.Disconnected - ) { - DaemonNavigation.ShowNoDaemonScreen - } else { - // Else, we don't have noDaemonScreen on backstack and don't do anything - null - } - } - // We don't care about null - .filterNotNull() - // Only care about changes - .distinctUntilChanged() - .collect { - when (it) { - DaemonNavigation.ShowNoDaemonScreen -> - navController.navigate(NoDaemonScreenDestination) - DaemonNavigation.RemoveNoDaemonScreen -> - navController.popBackStack(NoDaemonScreenDestination, true) - } + serviceVm.sideEffect.collect { + when (it) { + DaemonScreenEvent.Show -> navController.navigate(NoDaemonScreenDestination) + DaemonScreenEvent.Remove -> + navController.popBackStack(NoDaemonScreenDestination, true) } + } } // Globally show the changelog @@ -107,19 +74,3 @@ fun MullvadApp() { } } } - -private fun Destination.shouldHaveServiceConnection() = this !in noServiceDestinations - -sealed interface DaemonNavigation { - data object ShowNoDaemonScreen : DaemonNavigation - - data object RemoveNoDaemonScreen : DaemonNavigation -} - -fun NavController.backStackContains(destination: Destination) = - try { - getBackStackEntry(destination.route) - true - } catch (e: IllegalArgumentException) { - false - } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt index dcc53662bf40..f8fc20a6aac0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt @@ -20,13 +20,13 @@ object SettingsTransition : DestinationStyle.Animated { override fun AnimatedContentTransitionScope.exitTransition() = when (targetState.destination()) { NoDaemonScreenDestination -> fadeOut(snap(400)) - else -> slideOutHorizontally(targetOffsetX = { -it/3 }) + else -> slideOutHorizontally(targetOffsetX = { -it / 3 }) } override fun AnimatedContentTransitionScope.popEnterTransition() = when (initialState.destination()) { NoDaemonScreenDestination -> fadeIn(snap(0)) - else -> slideInHorizontally(initialOffsetX = { -it/3 }) + else -> slideInHorizontally(initialOffsetX = { -it / 3 }) } override fun AnimatedContentTransitionScope.popExitTransition() = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt index b11fdb536e98..a4473cb5e45c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -42,13 +42,13 @@ object SelectLocationTransition : DestinationStyle.Animated { override fun AnimatedContentTransitionScope.exitTransition() = when (targetState.destination()) { NoDaemonScreenDestination -> fadeOut(snap(400)) - else -> slideOutHorizontally { -it/3 } + else -> slideOutHorizontally { -it / 3 } } override fun AnimatedContentTransitionScope.popEnterTransition() = when (initialState.destination()) { NoDaemonScreenDestination -> fadeIn(snap(0)) - else -> slideInHorizontally { -it/3 } + else -> slideInHorizontally { -it / 3 } } override fun AnimatedContentTransitionScope.popExitTransition() = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt index 51708688517f..ab617712ee7b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -18,13 +18,13 @@ object SlideInFromRightTransition : DestinationStyle.Animated { override fun AnimatedContentTransitionScope.exitTransition() = when (targetState.destination()) { NoDaemonScreenDestination -> fadeOut(snap(400)) - else -> slideOutHorizontally(targetOffsetX = { -it/3 }) + else -> slideOutHorizontally(targetOffsetX = { -it / 3 }) } override fun AnimatedContentTransitionScope.popEnterTransition() = when (initialState.destination()) { NoDaemonScreenDestination -> fadeIn(snap(0)) - else -> slideInHorizontally(initialOffsetX = { -it/3 }) + else -> slideInHorizontally(initialOffsetX = { -it / 3 }) } override fun AnimatedContentTransitionScope.popExitTransition() = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Lifecycle.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Lifecycle.kt index f0b2b84e644a..d45162f076fa 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Lifecycle.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Lifecycle.kt @@ -8,8 +8,8 @@ import androidx.lifecycle.LifecycleEventObserver @Composable fun WhileInView( - inView: () -> Unit, - outOfView: () -> Unit, + inView: () -> Unit = {}, + outOfView: () -> Unit = {}, ) { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 166a5860177d..c41766fde846 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -45,12 +45,12 @@ import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel -import net.mullvad.mullvadvpn.viewmodel.ServiceConnectionViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel @@ -153,8 +153,10 @@ val uiModule = module { viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get()) } viewModel { PaymentViewModel(get()) } - viewModel { ServiceConnectionViewModel(get()) } viewModel { FilterViewModel(get()) } + + // This view model must be single so we correctly attach lifecycle and share it with activity + single { NoDaemonViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index c5b1add3a375..e0ee6cdd2128 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -21,6 +21,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules @@ -36,6 +37,7 @@ class MainActivity : ComponentActivity() { private lateinit var privacyDisclaimerRepository: PrivacyDisclaimerRepository private lateinit var serviceConnectionManager: ServiceConnectionManager private lateinit var changelogViewModel: ChangelogViewModel + private lateinit var serviceConnectionViewModel: NoDaemonViewModel override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(listOf(uiModule, paymentModule)) @@ -49,7 +51,9 @@ class MainActivity : ComponentActivity() { privacyDisclaimerRepository = get() serviceConnectionManager = get() changelogViewModel = get() + serviceConnectionViewModel = get() } + lifecycle.addObserver(serviceConnectionViewModel) super.onCreate(savedInstanceState) @@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { serviceConnectionManager.onDestroy() + lifecycle.removeObserver(serviceConnectionViewModel) super.onDestroy() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt new file mode 100644 index 000000000000..eb16562fa3d3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt @@ -0,0 +1,119 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import com.ramcosta.composedestinations.spec.DestinationSpec +import com.ramcosta.composedestinations.utils.destination +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.destinations.SplashDestination +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState + +private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) + +class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : + ViewModel(), LifecycleEventObserver, NavController.OnDestinationChangedListener { + + private val lifecycleFlow: MutableSharedFlow = MutableSharedFlow() + private val destinationFlow: MutableSharedFlow> = MutableSharedFlow() + + @OptIn(FlowPreview::class) + val sideEffect = + combine(lifecycleFlow, serviceConnectionManager.connectionState, destinationFlow) { + event, + connEvent, + destination -> + toDaemonState(event, connEvent, destination) + } + .map { state -> + when (state) { + is DaemonState.Show -> DaemonScreenEvent.Show + is DaemonState.Hidden.Ignored -> DaemonScreenEvent.Remove + DaemonState.Hidden.Connected -> DaemonScreenEvent.Remove + } + } + .distinctUntilChanged() + // We debounce any disconnected state to let the UI have some time to connect after a + // onStart/onStop event. + .debounce { + when (it) { + is DaemonScreenEvent.Remove -> 0.seconds + is DaemonScreenEvent.Show -> SERVICE_DISCONNECT_DEBOUNCE + } + } + .distinctUntilChanged() + .shareIn(viewModelScope, SharingStarted.Eagerly) + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + viewModelScope.launch { lifecycleFlow.emit(event) } + } + + private fun toDaemonState( + lifecycleEvent: Lifecycle.Event, + serviceState: ServiceConnectionState, + currentDestination: DestinationSpec<*> + ): DaemonState { + // In these destinations we don't care about showing the NoDaemonScreen + if (currentDestination in noServiceDestinations) { + return DaemonState.Hidden.Ignored + } + + return if (lifecycleEvent.targetState.isAtLeast(Lifecycle.State.STARTED)) { + // If we are started we want to show the overlay if we are not connected to daemon + when (serviceState) { + is ServiceConnectionState.ConnectedNotReady, + ServiceConnectionState.Disconnected -> DaemonState.Show + is ServiceConnectionState.ConnectedReady -> DaemonState.Hidden.Connected + } + } else { + // If we are stopped we intentionally stop service and don't care about showing overlay. + DaemonState.Hidden.Ignored + } + } + + companion object { + private val SERVICE_DISCONNECT_DEBOUNCE = 1.seconds + } + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + viewModelScope.launch { + controller.currentBackStackEntry?.destination()?.let { destinationFlow.emit(it) } + } + } +} + +sealed interface DaemonState { + data object Show : DaemonState + + sealed interface Hidden : DaemonState { + data object Ignored : Hidden + + data object Connected : Hidden + } +} + +sealed interface DaemonScreenEvent { + data object Show : DaemonScreenEvent + + data object Remove : DaemonScreenEvent +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt deleted file mode 100644 index baa13a67be2c..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.FlowPreview -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState - -class ServiceConnectionViewModel(serviceConnectionManager: ServiceConnectionManager) : ViewModel() { - @OptIn(FlowPreview::class) - val uiState = - serviceConnectionManager.connectionState - .map { - when (it) { - is ServiceConnectionState.ConnectedNotReady -> ServiceState.Disconnected - is ServiceConnectionState.ConnectedReady -> ServiceState.Connected - ServiceConnectionState.Disconnected -> ServiceState.Disconnected - } - } - // We debounce any disconnected state to let the UI have some time to connect after a - // onPaused/onResumed event. - .debounce { - when (it) { - is ServiceState.Connected -> 0.seconds - is ServiceState.Disconnected -> SERVICE_DISCONNECT_DEBOUNCE - } - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = ServiceState.Disconnected - ) - - - companion object { - private val SERVICE_DISCONNECT_DEBOUNCE = 1.seconds - } -} - -sealed interface ServiceState { - data object Disconnected : ServiceState - - data object Connected : ServiceState -}