summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt48
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt42
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt47
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt45
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt49
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt29
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt222
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt26
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/AccountExpiryMockApiTest.kt5
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"