Skip to content

Commit

Permalink
Rework NoDaemon overlay logic
Browse files Browse the repository at this point in the history
  • Loading branch information
Rawa committed Dec 6, 2023
1 parent 5e79902 commit ec2a6bb
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 124 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,45 @@ 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
fun MullvadApp() {
val engine = rememberNavHostEngine()
val navController: NavHostController = engine.rememberNavController()

val serviceVm = koinViewModel<ServiceConnectionViewModel>()
val serviceVm = koinViewModel<NoDaemonViewModel>()

DisposableEffect(Unit) {
navController.addOnDestinationChangedListener(serviceVm)
onDispose { navController.removeOnDestinationChangedListener(serviceVm) }
}

DestinationsNavHost(
modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(),
Expand All @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ object SettingsTransition : DestinationStyle.Animated {
override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
when (targetState.destination()) {
NoDaemonScreenDestination -> fadeOut(snap(400))
else -> slideOutHorizontally(targetOffsetX = { -it/3 })
else -> slideOutHorizontally(targetOffsetX = { -it / 3 })
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
when (initialState.destination()) {
NoDaemonScreenDestination -> fadeIn(snap(0))
else -> slideInHorizontally(initialOffsetX = { -it/3 })
else -> slideInHorizontally(initialOffsetX = { -it / 3 })
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ object SelectLocationTransition : DestinationStyle.Animated {
override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
when (targetState.destination()) {
NoDaemonScreenDestination -> fadeOut(snap(400))
else -> slideOutHorizontally { -it/3 }
else -> slideOutHorizontally { -it / 3 }
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
when (initialState.destination()) {
NoDaemonScreenDestination -> fadeIn(snap(0))
else -> slideInHorizontally { -it/3 }
else -> slideInHorizontally { -it / 3 }
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ object SlideInFromRightTransition : DestinationStyle.Animated {
override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
when (targetState.destination()) {
NoDaemonScreenDestination -> fadeOut(snap(400))
else -> slideOutHorizontally(targetOffsetX = { -it/3 })
else -> slideOutHorizontally(targetOffsetX = { -it / 3 })
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
when (initialState.destination()) {
NoDaemonScreenDestination -> fadeIn(snap(0))
else -> slideInHorizontally(initialOffsetX = { -it/3 })
else -> slideInHorizontally(initialOffsetX = { -it / 3 })
}

override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))
Expand All @@ -49,7 +51,9 @@ class MainActivity : ComponentActivity() {
privacyDisclaimerRepository = get()
serviceConnectionManager = get()
changelogViewModel = get()
serviceConnectionViewModel = get()
}
lifecycle.addObserver(serviceConnectionViewModel)

super.onCreate(savedInstanceState)

Expand Down Expand Up @@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() {

override fun onDestroy() {
serviceConnectionManager.onDestroy()
lifecycle.removeObserver(serviceConnectionViewModel)
super.onDestroy()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Lifecycle.Event> = MutableSharedFlow()
private val destinationFlow: MutableSharedFlow<DestinationSpec<*>> = 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
}
Loading

0 comments on commit ec2a6bb

Please sign in to comment.