diff options
39 files changed, 476 insertions, 408 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt index b4b9d66efd..0791f756b3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt @@ -8,7 +8,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +21,7 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.textfield.DnsTextField +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.DnsDialogSideEffect @@ -100,11 +100,9 @@ fun DnsDialog( val viewModel = koinViewModel<DnsDialogViewModel>(parameters = { parametersOf(initialValue, index) }) - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { - when (it) { - DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true) - } + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true) } } val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt index 91fb0287c9..e4a790fdb4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt @@ -8,7 +8,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -21,6 +20,7 @@ import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.textfield.MtuTextField +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.constant.MTU_MAX_VALUE import net.mullvad.mullvadvpn.constant.MTU_MIN_VALUE import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -42,11 +42,9 @@ private fun PreviewMtuDialog() { fun MtuDialog(mtuInitial: Int?, navigator: DestinationsNavigator) { val viewModel = koinViewModel<MtuDialogViewModel>() - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { - when (it) { - MtuDialogSideEffect.Complete -> navigator.navigateUp() - } + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + MtuDialogSideEffect.Complete -> navigator.navigateUp() } } MtuDialog( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt index 556e18c193..03e9434006 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt @@ -20,6 +20,7 @@ import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription @@ -125,12 +126,10 @@ fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boole val vm = koinViewModel<PaymentViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - is PaymentUiSideEffect.PaymentCancelled -> - resultBackNavigator.navigateBack(result = false) - } + LaunchedEffectCollect(vm.uiSideEffect) { + when (it) { + is PaymentUiSideEffect.PaymentCancelled -> + resultBackNavigator.navigateBack(result = false) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index d55cf6d2ca..ce7ff1b589 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -55,6 +54,7 @@ import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct @@ -171,22 +171,20 @@ fun AccountScreen( val clipboardManager = LocalClipboardManager.current val snackbarHostState = remember { SnackbarHostState() } val copyTextString = stringResource(id = R.string.copied_mullvad_account_number) - LaunchedEffect(Unit) { - uiSideEffect.collect { uiSideEffect -> - when (uiSideEffect) { - AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin() - is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> - context.openAccountPageInBrowser(uiSideEffect.token) - is AccountViewModel.UiSideEffect.CopyAccountNumber -> - launch { - clipboardManager.setText(AnnotatedString(uiSideEffect.accountNumber)) + LaunchedEffectCollect(uiSideEffect) { sideEffect -> + when (sideEffect) { + AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin() + is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> + context.openAccountPageInBrowser(sideEffect.token) + is AccountViewModel.UiSideEffect.CopyAccountNumber -> + launch { + clipboardManager.setText(AnnotatedString(sideEffect.accountNumber)) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar(message = copyTextString) - } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = copyTextString) } - } + } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 91b583a70b..fc13e053b8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf @@ -39,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -66,6 +66,7 @@ import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.constant.SECURE_ZOOM import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM @@ -111,27 +112,28 @@ fun Connect(navigator: DestinationsNavigator) { val state by connectViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(key1 = Unit) { - connectViewModel.uiSideEffect.collect { uiSideEffect -> - when (uiSideEffect) { - is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { - context.openAccountPageInBrowser(uiSideEffect.token) - } - is ConnectViewModel.UiSideEffect.OutOfTime -> { - navigator.navigate(OutOfTimeDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + + CollectSideEffectWithLifecycle( + connectViewModel.uiSideEffect, + minActiveState = Lifecycle.State.RESUMED + ) { sideEffect -> + when (sideEffect) { + is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { + context.openAccountPageInBrowser(sideEffect.token) + } + is ConnectViewModel.UiSideEffect.OutOfTime -> + navigator.navigate(OutOfTimeDestination, true) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - ConnectViewModel.UiSideEffect.RevokedDevice -> { - navigator.navigate(DeviceRevokedDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + ConnectViewModel.UiSideEffect.RevokedDevice -> + navigator.navigate(DeviceRevokedDestination, true) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - } } } + ConnectScreen( state = state, onDisconnectClick = connectViewModel::onDisconnectClick, @@ -139,7 +141,7 @@ fun Connect(navigator: DestinationsNavigator) { onConnectClick = connectViewModel::onConnectClick, onCancelClick = connectViewModel::onCancelClick, onSwitchLocationClick = { - navigator.navigate(SelectLocationDestination) { launchSingleTop = true } + navigator.navigate(SelectLocationDestination, true) { launchSingleTop = true } }, onUpdateVersionClick = { val intent = @@ -155,8 +157,12 @@ fun Connect(navigator: DestinationsNavigator) { context.startActivity(intent) }, onManageAccountClick = connectViewModel::onManageAccountClick, - onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, - onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + onSettingsClick = { + navigator.navigate(SettingsDestination, true) { launchSingleTop = true } + }, + onAccountClick = { + navigator.navigate(AccountDestination, true) { launchSingleTop = true } + }, onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index 2d5baef6bd..f5167bad54 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,6 +31,7 @@ import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.destinations.LoginDestination import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedSideEffect @@ -51,16 +51,13 @@ fun DeviceRevoked(navigator: DestinationsNavigator) { val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { sideEffect -> - when (sideEffect) { - DeviceRevokedSideEffect.NavigateToLogin -> { - navigator.navigate(LoginDestination()) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + LaunchedEffectCollect(viewModel.uiSideEffect) { sideEffect -> + when (sideEffect) { + DeviceRevokedSideEffect.NavigateToLogin -> + navigator.navigate(LoginDestination()) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt index 000ad9a1cd..bcd42d7c0c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -39,6 +38,7 @@ import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider import net.mullvad.mullvadvpn.compose.state.RelayFilterState import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Ownership @@ -72,11 +72,9 @@ fun FilterScreen(navigator: DestinationsNavigator) { val viewModel = koinViewModel<FilterViewModel>() val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { - when (it) { - FilterScreenSideEffect.CloseScreen -> navigator.navigateUp() - } + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + FilterScreenSideEffect.CloseScreen -> navigator.navigateUp() } } FilterScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index 7d84be162b..f4f617ec93 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -75,6 +75,7 @@ import net.mullvad.mullvadvpn.compose.test.LOGIN_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOGIN_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors import net.mullvad.mullvadvpn.compose.transitions.LoginTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -130,32 +131,27 @@ fun Login( } } - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - LoginUiSideEffect.NavigateToWelcome -> { - navigator.navigate(WelcomeDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + CollectSideEffectWithLifecycle(vm.uiSideEffect) { + when (it) { + LoginUiSideEffect.NavigateToWelcome -> + navigator.navigate(WelcomeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - is LoginUiSideEffect.NavigateToConnect -> { - navigator.navigate(ConnectDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + is LoginUiSideEffect.NavigateToConnect -> + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - is LoginUiSideEffect.TooManyDevices -> { - navigator.navigate(DeviceListDestination(it.accountToken.value)) { - launchSingleTop = true - } + is LoginUiSideEffect.TooManyDevices -> + navigator.navigate(DeviceListDestination(it.accountToken.value)) { + launchSingleTop = true + } + LoginUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - LoginUiSideEffect.NavigateToOutOfTime -> - navigator.navigate(OutOfTimeDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } - } } } LoginScreen( 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 8ef535a58b..508fcf67f3 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 @@ -3,7 +3,6 @@ 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 @@ -14,7 +13,6 @@ 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.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.compose.NavGraphs @@ -22,6 +20,7 @@ import net.mullvad.mullvadvpn.compose.destinations.ChangelogDestination import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel @@ -50,28 +49,23 @@ fun MullvadApp() { ) // Globally handle daemon dropped connection with NoDaemonScreen - LaunchedEffect(Unit) { - serviceVm.uiSideEffect.collect { - when (it) { - DaemonScreenEvent.Show -> - navController.navigate(NoDaemonScreenDestination) { launchSingleTop = true } - DaemonScreenEvent.Remove -> - navController.popBackStack(NoDaemonScreenDestination, true) - } + LaunchedEffectCollect(serviceVm.uiSideEffect) { + when (it) { + DaemonScreenEvent.Show -> + navController.navigate(NoDaemonScreenDestination) { launchSingleTop = true } + DaemonScreenEvent.Remove -> navController.popBackStack(NoDaemonScreenDestination, true) } } // Globally show the changelog val changeLogsViewModel = koinViewModel<ChangelogViewModel>() - LaunchedEffect(Unit) { - changeLogsViewModel.uiSideEffect.collect { + LaunchedEffectCollect(changeLogsViewModel.uiSideEffect) { - // Wait until we are in an acceptable destination - navController.currentBackStackEntryFlow - .map { it.destination() } - .first { it in changeLogDestinations } + // Wait until we are in an acceptable destination + navController.currentBackStackEntryFlow + .map { it.destination() } + .first { it in changeLogDestinations } - navController.navigate(ChangelogDestination(it).route) - } + navController.navigate(ChangelogDestination(it).route) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index 7aa1f90242..c5c99c62f5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +21,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -46,6 +46,7 @@ import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.test.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -135,18 +136,15 @@ fun OutOfTime( } val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { uiSideEffect -> - when (uiSideEffect) { - is OutOfTimeViewModel.UiSideEffect.OpenAccountView -> - openAccountPage(uiSideEffect.token) - OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> { - navigator.navigate(ConnectDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + CollectSideEffectWithLifecycle(vm.uiSideEffect, Lifecycle.State.RESUMED) { uiSideEffect -> + when (uiSideEffect) { + is OutOfTimeViewModel.UiSideEffect.OpenAccountView -> + openAccountPage(uiSideEffect.token) + OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index a7a7f3bce6..8173359e45 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -17,9 +17,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -48,6 +46,7 @@ import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.destinations.LoginDestination import net.mullvad.mullvadvpn.compose.destinations.SplashDestination +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.toDp import net.mullvad.mullvadvpn.constant.DAEMON_READY_TIMEOUT_MS import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -74,36 +73,30 @@ fun PrivacyDisclaimer( val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { - when (it) { - PrivacyDisclaimerUiSideEffect.NavigateToLogin -> { - navigator.navigate(LoginDestination(null)) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + PrivacyDisclaimerUiSideEffect.NavigateToLogin -> + navigator.navigate(LoginDestination(null)) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - PrivacyDisclaimerUiSideEffect.StartService -> { - scope.launch { - try { - withTimeout(DAEMON_READY_TIMEOUT_MS) { - (context as MainActivity).startServiceSuspend() - } - viewModel.onServiceStartedSuccessful() - } catch (e: CancellationException) { - // Timeout - viewModel.onServiceStartedTimeout() + PrivacyDisclaimerUiSideEffect.StartService -> + launch { + try { + withTimeout(DAEMON_READY_TIMEOUT_MS) { + (context as MainActivity).startServiceSuspend() } + viewModel.onServiceStartedSuccessful() + } catch (e: CancellationException) { + // Timeout + viewModel.onServiceStartedTimeout() } } - PrivacyDisclaimerUiSideEffect.NavigateToSplash -> { - navigator.navigate(SplashDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + PrivacyDisclaimerUiSideEffect.NavigateToSplash -> + navigator.navigate(SplashDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - } } } PrivacyDisclaimerScreen(state, {}, viewModel::setPrivacyDisclosureAccepted) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt index 6e72d26bcc..c46eb93894 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,6 +41,7 @@ import net.mullvad.mullvadvpn.compose.destinations.ReportProblemNoEmailDialogDes import net.mullvad.mullvadvpn.compose.destinations.ViewLogsDestination import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -102,13 +102,10 @@ fun ReportProblem( val vm = koinViewModel<ReportProblemViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - is ReportProblemSideEffect.ShowConfirmNoEmail -> { - navigator.navigate(ReportProblemNoEmailDialogDestination) - } - } + LaunchedEffectCollect(vm.uiSideEffect) { + when (it) { + is ReportProblemSideEffect.ShowConfirmNoEmail -> + navigator.navigate(ReportProblemNoEmailDialogDestination) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index b81cf8ebb6..1f17da8bc5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -51,6 +51,7 @@ import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar @@ -86,11 +87,9 @@ private fun PreviewSelectLocationScreen() { fun SelectLocation(navigator: DestinationsNavigator) { val vm = koinViewModel<SelectLocationViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() - } + LaunchedEffectCollect(vm.uiSideEffect) { + when (it) { + SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() } } @@ -99,7 +98,7 @@ fun SelectLocation(navigator: DestinationsNavigator) { onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, onBackClick = navigator::navigateUp, - onFilterClick = { navigator.navigate(FilterScreenDestination) }, + onFilterClick = { navigator.navigate(FilterScreenDestination, true) }, removeOwnershipFilter = vm::removeOwnerFilter, removeProviderFilter = vm::removeProviderFilter ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt index 0252c8129d..2a024b7a0a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.window.SplashScreen import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver @@ -33,6 +31,7 @@ import net.mullvad.mullvadvpn.compose.destinations.LoginDestination import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription @@ -53,37 +52,31 @@ private fun PreviewLoadingScreen() { fun Splash(navigator: DestinationsNavigator) { val viewModel: SplashViewModel = koinViewModel() - LaunchedEffect(Unit) { - viewModel.uiSideEffect.collect { - when (it) { - SplashUiSideEffect.NavigateToConnect -> { - navigator.navigate(ConnectDestination) { - popUpTo(NavGraphs.root) { inclusive = true } - } + // We use CollectSideEffectWithLifecycle to re-evaluate the splash decision if the user + // navigates away from the app to the resume before we leave the splash screen + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + SplashUiSideEffect.NavigateToConnect -> + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } } - SplashUiSideEffect.NavigateToLogin -> { - navigator.navigate(LoginDestination()) { - popUpTo(NavGraphs.root) { inclusive = true } - } + SplashUiSideEffect.NavigateToLogin -> + navigator.navigate(LoginDestination()) { + popUpTo(NavGraphs.root) { inclusive = true } } - SplashUiSideEffect.NavigateToPrivacyDisclaimer -> { - navigator.navigate(PrivacyDisclaimerDestination) { popUpTo(NavGraphs.root) {} } + SplashUiSideEffect.NavigateToPrivacyDisclaimer -> + navigator.navigate(PrivacyDisclaimerDestination) { popUpTo(NavGraphs.root) {} } + SplashUiSideEffect.NavigateToRevoked -> + navigator.navigate(DeviceRevokedDestination) { + popUpTo(NavGraphs.root) { inclusive = true } } - SplashUiSideEffect.NavigateToRevoked -> { - navigator.navigate(DeviceRevokedDestination) { - popUpTo(NavGraphs.root) { inclusive = true } - } + SplashUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + popUpTo(NavGraphs.root) { inclusive = true } } - SplashUiSideEffect.NavigateToOutOfTime -> - navigator.navigate(OutOfTimeDestination) { - popUpTo(NavGraphs.root) { inclusive = true } - } - } } } - LaunchedEffect(Unit) { viewModel.start() } - SplashScreen() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 6928e8fb43..0530cfb4ae 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,6 +74,7 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBE import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -158,17 +158,15 @@ fun VpnSettings( } val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - is VpnSettingsSideEffect.ShowToast -> - launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar(message = it.message) - } - VpnSettingsSideEffect.NavigateToDnsDialog -> - navigator.navigate(DnsDialogDestination(null, null)) { launchSingleTop = true } - } + LaunchedEffectCollect(vm.uiSideEffect) { + when (it) { + is VpnSettingsSideEffect.ShowToast -> + launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = it.message) + } + VpnSettingsSideEffect.NavigateToDnsDialog -> + navigator.navigate(DnsDialogDestination(null, null)) { launchSingleTop = true } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt index cb9989a4df..aef6602ab6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -26,6 +25,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -50,6 +50,7 @@ import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDest import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.compose.transitions.HomeTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser @@ -126,18 +127,17 @@ fun Welcome( } val context = LocalContext.current - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { uiSideEffect -> - when (uiSideEffect) { - is WelcomeViewModel.UiSideEffect.OpenAccountView -> - context.openAccountPageInBrowser(uiSideEffect.token) - WelcomeViewModel.UiSideEffect.OpenConnectScreen -> { - navigator.navigate(ConnectDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } + + CollectSideEffectWithLifecycle(sideEffect = vm.uiSideEffect, Lifecycle.State.RESUMED) { + uiSideEffect -> + when (uiSideEffect) { + is WelcomeViewModel.UiSideEffect.OpenAccountView -> + context.openAccountPageInBrowser(uiSideEffect.token) + WelcomeViewModel.UiSideEffect.OpenConnectScreen -> + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } } - } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt new file mode 100644 index 0000000000..42d5f6caa0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +@Composable +inline fun <T> LaunchedEffectCollect( + sideEffect: Flow<T>, + key: Any = Unit, + crossinline collector: suspend CoroutineScope.(T) -> Unit +) { + LaunchedEffect(key) { sideEffect.collect { collector(it) } } +} + +// This function will restart collection on Start/Stop events, e.g if the user navigates to home +// screen collection will stop, and then be restarted when the user opens the app again +@Composable +inline fun <T> CollectSideEffectWithLifecycle( + sideEffect: Flow<T>, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + key: Any? = Unit, + crossinline collector: suspend CoroutineScope.(T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(lifecycleOwner, key) { + sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { + collector(it) + } + } +} 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 0c96f72b3b..f36e579a52 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 @@ -108,7 +108,7 @@ val uiModule = module { single { NewDeviceNotificationUseCase(get()) } single { PortRangeUseCase(get()) } single { RelayListUseCase(get(), get()) } - single { OutOfTimeUseCase(get(), get()) } + single { OutOfTimeUseCase(get(), get(), MainScope()) } single { ConnectivityUseCase(get()) } single { SystemVpnSettingsUseCase(androidContext()) } @@ -154,7 +154,7 @@ val uiModule = module { viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } - viewModel { WelcomeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } + viewModel { WelcomeViewModel(get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt index 267a76f996..88ec42f986 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt @@ -1,12 +1,18 @@ package net.mullvad.mullvadvpn.usecase +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.ipc.Event import net.mullvad.mullvadvpn.lib.ipc.MessageHandler import net.mullvad.mullvadvpn.lib.ipc.events @@ -16,21 +22,19 @@ import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime -const val accountRefreshIntervalMillis = 60L * 1000L // 1 minute -const val bufferTimeMillis = 60L * 1000L // 1 minute - class OutOfTimeUseCase( private val repository: AccountRepository, - private val messageHandler: MessageHandler + private val messageHandler: MessageHandler, + scope: CoroutineScope ) { - fun isOutOfTime(): Flow<Boolean?> = + val isOutOfTime: StateFlow<Boolean?> = combine(pastAccountExpiry(), isTunnelBlockedBecauseOutOfTime()) { accountExpiryHasPast, tunnelOutOfTime -> reduce(accountExpiryHasPast, tunnelOutOfTime) } - .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, null) private fun reduce(vararg outOfTimeProperty: Boolean?): Boolean? = when { @@ -42,7 +46,7 @@ class OutOfTimeUseCase( else -> null } - private fun isTunnelBlockedBecauseOutOfTime() = + private fun isTunnelBlockedBecauseOutOfTime(): Flow<Boolean> = messageHandler .events<Event.TunnelStateChange>() .map { it.tunnelState.isTunnelErrorStateDueToExpiredAccount() } @@ -54,23 +58,22 @@ class OutOfTimeUseCase( } private fun pastAccountExpiry(): Flow<Boolean?> = - combine( - repository.accountExpiryState.map { + repository.accountExpiryState + .flatMapLatest { if (it is AccountExpiry.Available) { - it.date() + flow { + val millisUntilExpiry = it.expiryDateTime.millis - DateTime.now().millis + if (millisUntilExpiry > 0) { + emit(false) + delay(millisUntilExpiry) + emit(true) + } else { + emit(true) + } + } } else { - null + flowOf<Boolean?>(null) } - }, - timeFlow() - ) { expiryDate, time -> - expiryDate?.isBefore(time.plus(bufferTimeMillis)) - } - - private fun timeFlow() = flow { - while (true) { - emit(DateTime.now()) - delay(accountRefreshIntervalMillis) - } - } + } + .distinctUntilChanged() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index 0f8d624fc4..e3c0b226dd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -3,7 +3,6 @@ package net.mullvad.mullvadvpn.viewmodel import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -30,7 +29,7 @@ class AccountViewModel( deviceRepository: DeviceRepository, private val isPlayBuild: Boolean, ) : ViewModel() { - private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<UiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState: StateFlow<AccountUiState> = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 12427e2285..bebb0d6e42 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -3,7 +3,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow @@ -13,10 +12,10 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn @@ -58,8 +57,10 @@ class ConnectViewModel( private val paymentUseCase: PaymentUseCase, private val isPlayBuild: Boolean ) : ViewModel() { - private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) - val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _uiSideEffect = Channel<UiSideEffect>() + + val uiSideEffect = + merge(_uiSideEffect.receiveAsFlow(), outOfTimeEffect(), revokedDeviceEffect()) private val _shared: SharedFlow<ServiceConnectionContainer> = serviceConnectionManager.connectionState @@ -137,17 +138,6 @@ class ConnectViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) init { - viewModelScope.launch { - // When we get isOutOfTime true we will navigate to OutOfTime view. - outOfTimeUseCase.isOutOfTime().first { it == true } - _uiSideEffect.send(UiSideEffect.OutOfTime) - } - - viewModelScope.launch { - // When we get a revoked DeviceState we navigate to the RevokedDevice screen. - deviceRepository.deviceState.filterIsInstance<DeviceState.Revoked>().first() - _uiSideEffect.send(UiSideEffect.RevokedDevice) - } viewModelScope.launch { paymentUseCase.verifyPurchases { accountRepository.fetchAccountExpiry() } @@ -185,7 +175,7 @@ class ConnectViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.send( + _uiSideEffect.trySend( UiSideEffect.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -197,6 +187,14 @@ class ConnectViewModel( newDeviceNotificationUseCase.clearNewDeviceCreatedNotification() } + private fun outOfTimeEffect() = + outOfTimeUseCase.isOutOfTime.filter { it == true }.map { UiSideEffect.OutOfTime } + + private fun revokedDeviceEffect() = + deviceRepository.deviceState.filterIsInstance<DeviceState.Revoked>().map { + UiSideEffect.RevokedDevice + } + sealed interface UiSideEffect { data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt index 48a8782d04..7b6c092ded 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -36,7 +35,7 @@ class DeviceListViewModel( ) : ViewModel() { private val _loadingDevices = MutableStateFlow<List<DeviceId>>(emptyList()) - private val _uiSideEffect = Channel<DeviceListSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<DeviceListSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private var cachedDeviceList: List<Device>? = null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt index 5027d1cf13..4cb02c748f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest @@ -46,7 +45,7 @@ class DeviceRevokedViewModel( initialValue = DeviceRevokedUiState.UNKNOWN ) - private val _uiSideEffect = Channel<DeviceRevokedSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<DeviceRevokedSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() fun onGoToLoginClicked() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt index b931d4a7ba..4703e1cbf9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -81,7 +80,7 @@ class DnsDialogViewModel( createViewState(_ipAddressInput.value, vmState.value) ) - private val _uiSideEffect = Channel<DnsDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<DnsDialogSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private fun createViewState(ipAddress: String, vmState: DnsDialogViewModelState) = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt index 3f95d79193..0d39ffa625 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -2,7 +2,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -24,7 +23,7 @@ import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase class FilterViewModel( private val relayListFilterUseCase: RelayListFilterUseCase, ) : ViewModel() { - private val _uiSideEffect = Channel<FilterScreenSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<FilterScreenSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private val selectedOwnership = MutableStateFlow<Ownership?>(null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 2de5d42a05..9af9d700ce 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -59,7 +58,7 @@ class LoginViewModel( private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState) private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput) - private val _uiSideEffect = Channel<LoginUiSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<LoginUiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private val _uiState = @@ -102,6 +101,7 @@ class LoginViewModel( // If timed out will go to the else branch when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) { LoginResult.Ok -> { + newDeviceNotificationUseCase.newDeviceCreated() launch { val isOutOfTimeDeferred = async { accountRepository.accountExpiryState @@ -117,7 +117,6 @@ class LoginViewModel( _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) } } - newDeviceNotificationUseCase.newDeviceCreated() Success } LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt index db324e0b13..4b6e8ed767 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -16,7 +15,7 @@ class MtuDialogViewModel( private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _uiSideEffect = Channel<MtuDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<MtuDialogSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() fun onSaveClick(mtuValue: Int) = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 833ccf77d2..3c70717e47 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -2,16 +2,17 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -40,8 +41,8 @@ class OutOfTimeViewModel( private val isPlayBuild: Boolean ) : ViewModel() { - private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) - val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _uiSideEffect = Channel<UiSideEffect>() + val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), notOutOfTimeEffect()) val uiState = serviceConnectionManager.connectionState @@ -70,12 +71,6 @@ class OutOfTimeViewModel( init { viewModelScope.launch { - outOfTimeUseCase.isOutOfTime().first { it == false } - paymentUseCase.resetPurchaseResult() - _uiSideEffect.send(UiSideEffect.OpenConnectScreen) - } - - viewModelScope.launch { while (pollAccountExpiry) { updateAccountExpiry() delay(ACCOUNT_EXPIRY_POLL_INTERVAL) @@ -134,6 +129,14 @@ class OutOfTimeViewModel( accountRepository.fetchAccountExpiry() } + private fun notOutOfTimeEffect() = + outOfTimeUseCase.isOutOfTime + .filter { it == false } + .map { + paymentUseCase.resetPurchaseResult() + UiSideEffect.OpenConnectScreen + } + sealed interface UiSideEffect { data class OpenAccountView(val token: String) : UiSideEffect diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt index d765698b90..0e92a39678 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt @@ -2,7 +2,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow @@ -19,8 +18,7 @@ class PrivacyDisclaimerViewModel( private val _uiState = MutableStateFlow(PrivacyDisclaimerViewState(false)) val uiState = _uiState - private val _uiSideEffect = - Channel<PrivacyDisclaimerUiSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<PrivacyDisclaimerUiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() fun setPrivacyDisclosureAccepted() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt index 52311f82a0..208c65a312 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -3,7 +3,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -56,7 +55,7 @@ class ReportProblemViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReportProblemUiState()) - private val _uiSideEffect = Channel<ReportProblemSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<ReportProblemSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() fun sendReport(email: String, description: String, skipEmptyEmailCheck: Boolean = false) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 9b83423a35..4ca27a105a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -2,7 +2,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -82,7 +81,7 @@ class SelectLocationViewModel( SelectLocationUiState.Loading ) - private val _uiSideEffect = Channel<SelectLocationSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<SelectLocationSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() init { @@ -93,7 +92,7 @@ class SelectLocationViewModel( val locationConstraint = relayItem.toLocationConstraint() relayListUseCase.updateSelectedRelayLocation(locationConstraint) serviceConnectionManager.connectionProxy()?.connect() - viewModelScope.launch { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) } + _uiSideEffect.trySend(SelectLocationSideEffect.CloseScreen) } fun onSearchTermInput(searchTerm: String) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt index 1a7937e9bf..83442059da 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -3,45 +3,33 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_TIMEOUT_MS -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.events import net.mullvad.mullvadvpn.model.AccountAndDevice import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class SplashViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, private val deviceRepository: DeviceRepository, - private val messageHandler: MessageHandler, + private val accountRepository: AccountRepository, ) : ViewModel() { - private val _uiSideEffect = Channel<SplashUiSideEffect>(1, BufferOverflow.DROP_OLDEST) - val uiSideEffect = _uiSideEffect.receiveAsFlow() + val uiSideEffect = flow { emit(getStartDestination()) } - fun start() { - viewModelScope.launch { - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - _uiSideEffect.send(getStartDestination()) - } else { - _uiSideEffect.send(SplashUiSideEffect.NavigateToPrivacyDisclaimer) - } + private suspend fun getStartDestination(): SplashUiSideEffect { + if (!privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + return SplashUiSideEffect.NavigateToPrivacyDisclaimer } - } - private suspend fun getStartDestination(): SplashUiSideEffect { val deviceState = deviceRepository.deviceState .map { @@ -68,7 +56,7 @@ class SplashViewModel( private suspend fun getLoggedInStartDestination(): SplashUiSideEffect { val expiry = viewModelScope.async { - messageHandler.events<Event.AccountExpiryEvent>().map { it.expiry }.first() + accountRepository.accountExpiryState.first { it !is AccountExpiry.Missing } } val accountExpiry = select { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt index 3a5514d3d3..b954aff5cf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt @@ -8,7 +8,6 @@ import java.net.InetAddress import java.net.UnknownHostException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -54,7 +53,7 @@ class VpnSettingsViewModel( private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _uiSideEffect = Channel<VpnSettingsSideEffect>(1, BufferOverflow.DROP_OLDEST) + private val _uiSideEffect = Channel<VpnSettingsSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private val customPort = MutableStateFlow<Constraint<Port>?>(null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 6a2488c961..0f6b23a306 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -3,7 +3,6 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -11,9 +10,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -26,7 +29,6 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS import net.mullvad.mullvadvpn.util.addDebounceForUnknownState @@ -39,12 +41,11 @@ class WelcomeViewModel( private val deviceRepository: DeviceRepository, private val serviceConnectionManager: ServiceConnectionManager, private val paymentUseCase: PaymentUseCase, - private val outOfTimeUseCase: OutOfTimeUseCase, private val pollAccountExpiry: Boolean = true, private val isPlayBuild: Boolean ) : ViewModel() { - private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) - val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _uiSideEffect = Channel<UiSideEffect>() + val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), hasAddedTimeEffect()) val uiState = serviceConnectionManager.connectionState @@ -81,15 +82,17 @@ class WelcomeViewModel( delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } - viewModelScope.launch { - outOfTimeUseCase.isOutOfTime().first { it == false } - paymentUseCase.resetPurchaseResult() - _uiSideEffect.send(UiSideEffect.OpenConnectScreen) - } verifyPurchases() fetchPaymentAvailability() } + private fun hasAddedTimeEffect() = + accountRepository.accountExpiryState + .mapNotNull { it.date() } + .filter { it.minusHours(MIN_HOURS_PAST_ACCOUNT_EXPIRY).isAfterNow } + .onEach { paymentUseCase.resetPurchaseResult() } + .map { UiSideEffect.OpenConnectScreen } + private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = callbackFlowFromNotifier(this.onUiStateChange) @@ -140,4 +143,8 @@ class WelcomeViewModel( data object OpenConnectScreen : UiSideEffect } + + companion object { + private const val MIN_HOURS_PAST_ACCOUNT_EXPIRY = 20 + } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt index d40e8d8e18..326e183445 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt @@ -3,10 +3,19 @@ package net.mullvad.mullvadvpn.usecase import app.cash.turbine.test import io.mockk.every import io.mockk.mockk +import io.mockk.unmockkAll import kotlin.test.assertEquals -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlin.time.Duration.Companion.days +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import net.mullvad.mullvadvpn.lib.ipc.Event import net.mullvad.mullvadvpn.lib.ipc.MessageHandler import net.mullvad.mullvadvpn.lib.ipc.events @@ -16,6 +25,7 @@ import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -23,106 +33,194 @@ class OutOfTimeUseCaseTest { private val mockAccountRepository: AccountRepository = mockk() private val mockMessageHandler: MessageHandler = mockk() - private val events = MutableSharedFlow<Event.TunnelStateChange>() - private val expiry = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + private lateinit var events: Channel<Event.TunnelStateChange> + private lateinit var expiry: MutableStateFlow<AccountExpiry> - lateinit var outOfTimeUseCase: OutOfTimeUseCase + private val dispatcher = StandardTestDispatcher() + private val scope = TestScope(dispatcher) + + private lateinit var outOfTimeUseCase: OutOfTimeUseCase @BeforeEach fun setup() { + events = Channel() + expiry = MutableStateFlow(AccountExpiry.Missing) every { mockAccountRepository.accountExpiryState } returns expiry - every { mockMessageHandler.events<Event.TunnelStateChange>() } returns events - outOfTimeUseCase = OutOfTimeUseCase(mockAccountRepository, mockMessageHandler) + every { mockMessageHandler.events<Event.TunnelStateChange>() } returns + events.receiveAsFlow() + + Dispatchers.setMain(dispatcher) + + outOfTimeUseCase = + OutOfTimeUseCase(mockAccountRepository, mockMessageHandler, scope.backgroundScope) } - @Test - fun `no events should result in no expiry`() = runTest { - // Arrange - // Act, Assert - outOfTimeUseCase.isOutOfTime().test { assertEquals(null, awaitItem()) } + @AfterEach + fun teardown() { + Dispatchers.resetMain() + unmockkAll() } @Test - fun `tunnel is blocking because out of time should emit true`() = runTest { - // Arrange - // Act, Assert - val errorStateCause = ErrorStateCause.AuthFailed("[EXPIRED_ACCOUNT]") - val tunnelStateError = TunnelState.Error(ErrorState(errorStateCause, true)) - val errorChange = Event.TunnelStateChange(tunnelStateError) + fun `no events should result in no expiry`() = + scope.runTest { + // Arrange + // Act, Assert + outOfTimeUseCase.isOutOfTime.test { assertEquals(null, awaitItem()) } + } - outOfTimeUseCase.isOutOfTime().test { - assertEquals(null, awaitItem()) - events.emit(errorChange) - assertEquals(true, awaitItem()) + @Test + fun `tunnel is blocking because out of time should emit true`() = + scope.runTest { + // Arrange + // Act, Assert + val errorStateCause = ErrorStateCause.AuthFailed("[EXPIRED_ACCOUNT]") + val tunnelStateError = TunnelState.Error(ErrorState(errorStateCause, true)) + val errorChange = Event.TunnelStateChange(tunnelStateError) + + outOfTimeUseCase.isOutOfTime.test { + assertEquals(null, awaitItem()) + events.send(errorChange) + assertEquals(true, awaitItem()) + } } - } @Test - fun `tunnel is connected should emit false`() = runTest { - // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) - val tunnelStateChanges = - listOf( - TunnelState.Disconnected(), - TunnelState.Connected(mockk(), null), - TunnelState.Connecting(null, null), - TunnelState.Disconnecting(mockk()), - TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), - ) - .map(Event::TunnelStateChange) + fun `tunnel is connected should emit false`() = + scope.runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + val tunnelStateChanges = + listOf( + TunnelState.Disconnected(), + TunnelState.Connected(mockk(), null), + TunnelState.Connecting(null, null), + TunnelState.Disconnecting(mockk()), + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), + ) + .map(Event::TunnelStateChange) - // Act, Assert - outOfTimeUseCase.isOutOfTime().test { - assertEquals(null, awaitItem()) - events.emit(tunnelStateChanges.first()) - expiry.emit(expiredAccountExpiry) - assertEquals(false, awaitItem()) + // Act, Assert + outOfTimeUseCase.isOutOfTime.test { + assertEquals(null, awaitItem()) + events.send(tunnelStateChanges.first()) + expiry.emit(expiredAccountExpiry) + assertEquals(false, awaitItem()) - tunnelStateChanges.forEach { events.emit(it) } + tunnelStateChanges.forEach { events.send(it) } - // Should not emit again - expectNoEvents() + // Should not emit again + expectNoEvents() + } } - } @Test - fun `account expiry that has expired should emit true`() = runTest { - // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().minusDays(1)) - // Act, Assert - outOfTimeUseCase.isOutOfTime().test { - assertEquals(null, awaitItem()) - expiry.emit(expiredAccountExpiry) - assertEquals(true, awaitItem()) + fun `account expiry that has expired should emit true`() = + scope.runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().minusDays(1)) + // Act, Assert + outOfTimeUseCase.isOutOfTime.test { + assertEquals(null, awaitItem()) + expiry.emit(expiredAccountExpiry) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `account expiry that has not expired should emit false`() = + scope.runTest { + // Arrange + val notExpiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + + // Act, Assert + outOfTimeUseCase.isOutOfTime.test { + assertEquals(null, awaitItem()) + expiry.emit(notExpiredAccountExpiry) + assertEquals(false, awaitItem()) + } } - } @Test - fun `account expiry that has not expired should emit false`() = runTest { + fun `account that expires without new expiry event should emit true`() = + runTest(dispatcher) { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + // Act, Assert + outOfTimeUseCase.isOutOfTime.test { + // Initial event + assertEquals(null, awaitItem()) + + expiry.emit(expiredAccountExpiry) + assertEquals(false, awaitItem()) + + // After 50 seconds we should still not emitted out of time + advanceTimeBy(50_000) + expectNoEvents() + + // After additional 50 seconds we should be out of time since account is now expired + advanceTimeBy(50_000) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `account that is about to expire but is refilled should emit false`() = runTest { // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + val initialAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + val updatedExpiry = + AccountExpiry.Available(initialAccountExpiry.expiryDateTime.plusDays(30)) // Act, Assert - outOfTimeUseCase.isOutOfTime().test { + outOfTimeUseCase.isOutOfTime.test { + // Initial event assertEquals(null, awaitItem()) - expiry.emit(expiredAccountExpiry) + + expiry.emit(initialAccountExpiry) assertEquals(false, awaitItem()) + advanceTimeBy(90_000) + expectNoEvents() + + // User fills up with more time 30 seconds before expiry + expiry.emit(updatedExpiry) + advanceTimeBy(1.days) + expectNoEvents() + + // Expect no more emissions while user has time. + advanceTimeBy(29.days) + assertEquals(true, awaitItem()) + expectNoEvents() } } @Test - fun `account that expires without new expiry event should emit true`() = runTest { + fun `expired account that is refilled should emit false`() = runTest { // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(62)) - + val initialAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + val updatedExpiry = + AccountExpiry.Available(initialAccountExpiry.expiryDateTime.plusDays(30)) // Act, Assert - outOfTimeUseCase.isOutOfTime().test { + outOfTimeUseCase.isOutOfTime.test { // Initial event assertEquals(null, awaitItem()) - expiry.emit(expiredAccountExpiry) + expiry.emit(initialAccountExpiry) assertEquals(false, awaitItem()) + + // After 100 seconds we expire + advanceTimeBy(100_000) assertEquals(true, awaitItem()) + expectNoEvents() + + // We then fill up our account and should no longer be out of time + expiry.emit(updatedExpiry) + assertEquals(false, awaitItem()) + expectNoEvents() + + // Advance the time to the updated expiry + advanceTimeBy(30.days) + assertEquals(true, awaitItem()) + expectNoEvents() } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 545422b6f2..7e207a15a4 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -132,7 +132,7 @@ class ConnectViewModelTest { // Flows every { mockRelayListUseCase.selectedRelayItem() } returns selectedRelayItemFlow - every { outOfTimeUseCase.isOutOfTime() } returns outOfTimeViewFlow + every { outOfTimeUseCase.isOutOfTime } returns outOfTimeViewFlow viewModel = ConnectViewModel( serviceConnectionManager = mockServiceConnectionManager, diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt index a5171b2ea6..e489c01d41 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -89,7 +89,7 @@ class OutOfTimeViewModelTest { coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow - coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow + coEvery { mockOutOfTimeUseCase.isOutOfTime } returns outOfTimeFlow viewModel = OutOfTimeViewModel( diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt index 74ea210a60..91554193bc 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt @@ -32,11 +32,9 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime -import org.joda.time.ReadableInstant import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -51,7 +49,6 @@ class WelcomeViewModelTest { private val accountExpiryStateFlow = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) private val purchaseResultFlow = MutableStateFlow<PurchaseResult?>(null) private val paymentAvailabilityFlow = MutableStateFlow<PaymentAvailability?>(null) - private val outOfTimeFlow = MutableStateFlow(true) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -64,7 +61,6 @@ class WelcomeViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) - private val mockOutOfTimeUseCase: OutOfTimeUseCase = mockk(relaxed = true) private lateinit var viewModel: WelcomeViewModel @@ -87,15 +83,12 @@ class WelcomeViewModelTest { coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow - coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow - viewModel = WelcomeViewModel( accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, serviceConnectionManager = mockServiceConnectionManager, paymentUseCase = mockPaymentUseCase, - outOfTimeUseCase = mockOutOfTimeUseCase, pollAccountExpiry = false, isPlayBuild = false ) @@ -164,19 +157,16 @@ class WelcomeViewModelTest { } @Test - fun `when OutOfTimeUseCase return false uiSideEffect should emit OpenConnectScreen`() = - runTest { - // Arrange - val mockExpiryDate: DateTime = mockk() - every { mockExpiryDate.isAfter(any<ReadableInstant>()) } returns true + fun `when user has added time then uiSideEffect should emit OpenConnectScreen`() = runTest { + // Arrange + accountExpiryStateFlow.emit(AccountExpiry.Available(DateTime().plusDays(1))) - // Act, Assert - viewModel.uiSideEffect.test { - outOfTimeFlow.value = false - val action = awaitItem() - assertIs<WelcomeViewModel.UiSideEffect.OpenConnectScreen>(action) - } + // Act, Assert + viewModel.uiSideEffect.test { + val action = awaitItem() + assertIs<WelcomeViewModel.UiSideEffect.OpenConnectScreen>(action) } + } @Test fun `when paymentAvailability emits ProductsUnavailable uiState should include state NoPayment`() = diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt index 469040c4c4..b2699a2c73 100644 --- a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt @@ -10,7 +10,6 @@ import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_DEVICE_NAME_2 import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_ID_2 import net.mullvad.mullvadvpn.test.mockapi.util.currentUtcTimeWithOffsetZero import net.mullvad.mullvadvpn.util.toExpiryDateString -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test class AccountExpiryMockApiTest : MockApiTest() { @@ -50,10 +49,6 @@ class AccountExpiryMockApiTest : MockApiTest() { } @Test - @Disabled( - "Disabled since we have a bug in the app that makes it unstable. " + - "We can restore it after the bug has been fixed" - ) fun testAccountTimeExpiredWhileUsingTheAppShouldShowOutOfTimeScreen() { // Arrange val validAccountToken = "1234123412341234" |
