summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt64
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt120
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt153
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt57
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt111
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt19
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt55
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt19
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt193
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt13
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt12
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt4
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt597
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt231
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt63
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt136
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt42
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt139
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt42
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt159
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt106
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt71
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt75
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt81
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt77
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt104
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt153
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt52
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt99
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt279
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt44
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt139
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt373
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt159
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt58
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt283
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt49
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt171
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt39
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt119
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt110
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt278
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt80
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt50
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt26
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt66
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt14
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt4
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt89
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt70
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt192
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt4
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt27
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt129
108 files changed, 4072 insertions, 3003 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt
new file mode 100644
index 0000000000..43e385b65d
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt
@@ -0,0 +1,64 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTextInput
+import io.mockk.MockKAnnotations
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG
+import net.mullvad.mullvadvpn.model.PortRange
+import net.mullvad.mullvadvpn.onNodeWithTagAndText
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class CustomPortDialogTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @SuppressLint("ComposableNaming")
+ @Composable
+ private fun testWireguardCustomPortDialog(
+ initialPort: Int? = null,
+ allowedPortRanges: List<PortRange> = emptyList(),
+ onSave: (Int?) -> Unit = { _ -> },
+ onDismiss: () -> Unit = {},
+ ) {
+
+ WireguardCustomPortDialog(
+ initialPort = initialPort,
+ allowedPortRanges = allowedPortRanges,
+ onSave = onSave,
+ onDismiss = onDismiss
+ )
+ }
+
+ @Test
+ fun testShowWireguardCustomPortDialogInvalidInt() {
+ // Input a number to make sure that a too long number does not show and it does not crash
+ // the app
+
+ // Arrange
+ composeTestRule.setContentWithTheme { testWireguardCustomPortDialog() }
+
+ // Act
+ composeTestRule
+ .onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG)
+ .performTextInput(invalidCustomPort)
+
+ // Assert
+ composeTestRule
+ .onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, invalidCustomPort)
+ .assertDoesNotExist()
+ }
+
+ companion object {
+ const val invalidCustomPort = "21474836471"
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt
new file mode 100644
index 0000000000..bc8d87b244
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt
@@ -0,0 +1,120 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState
+import org.junit.Rule
+import org.junit.Test
+
+class DnsDialogTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ private val defaultState =
+ DnsDialogViewState(
+ ipAddress = "",
+ validationResult = DnsDialogViewState.ValidationResult.Success,
+ isLocal = false,
+ isAllowLanEnabled = false,
+ isNewEntry = true
+ )
+
+ @SuppressLint("ComposableNaming")
+ @Composable
+ private fun testDnsDialog(
+ state: DnsDialogViewState = defaultState,
+ onDnsInputChange: (String) -> Unit = { _ -> },
+ onSaveDnsClick: () -> Unit = {},
+ onRemoveDnsClick: () -> Unit = {},
+ onDismiss: () -> Unit = {}
+ ) {
+ DnsDialog(state, onDnsInputChange, onSaveDnsClick, onRemoveDnsClick, onDismiss)
+ }
+
+ @Test
+ fun testDnsDialogLanWarningShownWhenLanTrafficDisabledAndLocalAddressUsed() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(defaultState.copy(isAllowLanEnabled = false, isLocal = true))
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertExists()
+ }
+
+ @Test
+ fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndLocalAddressUsed() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(defaultState.copy(isAllowLanEnabled = true, isLocal = true))
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
+ }
+
+ @Test
+ fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndNonLocalAddressUsed() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(defaultState.copy(isAllowLanEnabled = true, isLocal = false))
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
+ }
+
+ @Test
+ fun testDnsDialogLanWarningNotShownWhenLanTrafficDisabledAndNonLocalAddressUsed() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(defaultState.copy(isAllowLanEnabled = false, isLocal = false))
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
+ }
+
+ @Test
+ fun testDnsDialogSubmitButtonDisabledOnInvalidDnsAddress() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(
+ defaultState.copy(
+ ipAddress = invalidIpAddress,
+ validationResult = DnsDialogViewState.ValidationResult.InvalidAddress,
+ )
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
+ }
+
+ @Test
+ fun testDnsDialogSubmitButtonDisabledOnDuplicateDnsAddress() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testDnsDialog(
+ defaultState.copy(
+ ipAddress = "192.168.0.1",
+ validationResult = DnsDialogViewState.ValidationResult.DuplicateAddress,
+ )
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
+ }
+
+ companion object {
+ private const val LOCAL_DNS_SERVER_WARNING =
+ "The local DNS server will not work unless you enable " +
+ "\"Local Network Sharing\" under Preferences."
+
+ private const val invalidIpAddress = "300.300.300.300"
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt
new file mode 100644
index 0000000000..38a3bd170d
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt
@@ -0,0 +1,153 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.verify
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class MtuDialogTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @SuppressLint("ComposableNaming")
+ @Composable
+ private fun testMtuDialog(
+ mtuInitial: Int? = null,
+ onSaveMtu: (Int) -> Unit = { _ -> },
+ onResetMtu: () -> Unit = {},
+ onDismiss: () -> Unit = {},
+ ) {
+ MtuDialog(
+ mtuInitial = mtuInitial,
+ onSaveMtu = onSaveMtu,
+ onResetMtu = onResetMtu,
+ onDismiss = onDismiss
+ )
+ }
+
+ @Test
+ fun testMtuDialogWithDefaultValue() {
+ // Arrange
+ composeTestRule.setContentWithTheme { testMtuDialog() }
+
+ // Assert
+ composeTestRule.onNodeWithText(EMPTY_STRING).assertExists()
+ }
+
+ @Test
+ fun testMtuDialogWithEditValue() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ mtuInitial = VALID_DUMMY_MTU_VALUE,
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText(VALID_DUMMY_MTU_VALUE.toString()).assertExists()
+ }
+
+ @Test
+ fun testMtuDialogTextInput() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ null,
+ )
+ }
+
+ // Act
+ composeTestRule
+ .onNodeWithText(EMPTY_STRING)
+ .performTextInput(VALID_DUMMY_MTU_VALUE.toString())
+
+ // Assert
+ composeTestRule.onNodeWithText(VALID_DUMMY_MTU_VALUE.toString()).assertExists()
+ }
+
+ @Test
+ fun testMtuDialogSubmitOfValidValue() {
+ // Arrange
+ val mockedSubmitHandler: (Int) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ VALID_DUMMY_MTU_VALUE,
+ onSaveMtu = mockedSubmitHandler,
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Submit").assertIsEnabled().performClick()
+
+ // Assert
+ verify { mockedSubmitHandler.invoke(VALID_DUMMY_MTU_VALUE) }
+ }
+
+ @Test
+ fun testMtuDialogSubmitButtonDisabledWhenInvalidInput() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ INVALID_DUMMY_MTU_VALUE,
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
+ }
+
+ @Test
+ fun testMtuDialogResetClick() {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ onResetMtu = mockedClickHandler,
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Reset to default").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ @Test
+ fun testMtuDialogCancelClick() {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContentWithTheme {
+ testMtuDialog(
+ onDismiss = mockedClickHandler,
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Cancel").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ companion object {
+ private const val EMPTY_STRING = ""
+ private const val VALID_DUMMY_MTU_VALUE = 1337
+ private const val INVALID_DUMMY_MTU_VALUE = 1111
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt
new file mode 100644
index 0000000000..1a626ecf19
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt
@@ -0,0 +1,57 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+import org.junit.Rule
+import org.junit.Test
+
+class PaymentDialogTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun testShowPurchaseCompleteDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ PaymentDialog(
+ paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()!!
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Time was successfully added").assertExists()
+ }
+
+ @Test
+ fun testShowVerificationErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ PaymentDialog(
+ paymentDialogData =
+ PurchaseResult.Error.VerificationError(null).toPaymentDialogData()!!
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testShowFetchProductsErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ PaymentDialog(
+ paymentDialogData =
+ PurchaseResult.Error.FetchProductsError(ProductId(""), null)
+ .toPaymentDialogData()!!
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
index e997ae29e4..3b42cc1c3b 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
@@ -19,8 +18,6 @@ import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
-import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.viewmodel.AccountUiState
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import org.junit.Before
@@ -41,15 +38,14 @@ class AccountScreenTest {
// Arrange
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
accountNumber = DUMMY_ACCOUNT_NUMBER,
- accountExpiry = null
+ accountExpiry = null,
+ showSitePayment = false
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -66,15 +62,14 @@ class AccountScreenTest {
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState(
+ showSitePayment = true,
deviceName = DUMMY_DEVICE_NAME,
accountNumber = DUMMY_ACCOUNT_NUMBER,
- accountExpiry = null
+ accountExpiry = null,
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(),
onManageAccountClick = mockedClickHandler
)
}
@@ -92,15 +87,14 @@ class AccountScreenTest {
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
accountNumber = DUMMY_ACCOUNT_NUMBER,
- accountExpiry = null
+ accountExpiry = null,
+ showSitePayment = false
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(),
onRedeemVoucherClick = mockedClickHandler
)
}
@@ -118,15 +112,14 @@ class AccountScreenTest {
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
accountNumber = DUMMY_ACCOUNT_NUMBER,
- accountExpiry = null
+ accountExpiry = null,
+ showSitePayment = false
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(),
onLogoutClick = mockedClickHandler
)
}
@@ -139,79 +132,13 @@ class AccountScreenTest {
}
@Test
- fun testShowPurchaseCompleteDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- AccountScreen(
- showSitePayment = true,
- uiState =
- AccountUiState.default()
- .copy(
- paymentDialogData =
- PurchaseResult.Completed.Success.toPaymentDialogData()
- ),
- uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Time was successfully added").assertExists()
- }
-
- @Test
- fun testShowVerificationErrorDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- AccountScreen(
- showSitePayment = true,
- uiState =
- AccountUiState.default()
- .copy(
- paymentDialogData =
- PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
- ),
- uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Verifying purchase").assertExists()
- }
-
- @Test
- fun testShowFetchProductsErrorDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- AccountScreen(
- showSitePayment = true,
- uiState =
- AccountUiState.default()
- .copy(
- paymentDialogData =
- PurchaseResult.Error.FetchProductsError(ProductId(""), null)
- .toPaymentDialogData()
- ),
- uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
- }
-
- @Test
fun testShowBillingErrorPaymentButton() {
// Arrange
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default().copy(billingPaymentState = PaymentState.Error.Billing),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -227,7 +154,6 @@ class AccountScreenTest {
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default()
.copy(
@@ -235,7 +161,6 @@ class AccountScreenTest {
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -251,7 +176,6 @@ class AccountScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default()
.copy(
@@ -259,7 +183,6 @@ class AccountScreenTest {
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -273,9 +196,9 @@ class AccountScreenTest {
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default()
.copy(
@@ -283,7 +206,7 @@ class AccountScreenTest {
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ navigateToVerificationPendingDialog = mockNavigateToVerificationPending
)
}
@@ -291,11 +214,7 @@ class AccountScreenTest {
composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
// Assert
- composeTestRule
- .onNodeWithText(
- "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
- )
- .assertExists()
+ verify(exactly = 1) { mockNavigateToVerificationPending.invoke() }
}
@Test
@@ -306,7 +225,6 @@ class AccountScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default()
.copy(
@@ -314,7 +232,6 @@ class AccountScreenTest {
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -325,14 +242,13 @@ class AccountScreenTest {
@Test
fun testOnPurchaseBillingProductClick() {
// Arrange
- val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val clickHandler: (ProductId) -> Unit = mockk(relaxed = true)
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState.default()
.copy(
@@ -341,7 +257,6 @@ class AccountScreenTest {
),
onPurchaseBillingProductClick = clickHandler,
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -349,7 +264,7 @@ class AccountScreenTest {
composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
// Assert
- verify { clickHandler.invoke(ProductId("PRODUCT_ID"), any()) }
+ verify { clickHandler.invoke(ProductId("PRODUCT_ID")) }
}
companion object {
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
index ab8f2b1512..4e34fe0825 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
@@ -9,10 +9,9 @@ import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableStateFlow
import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState
+import net.mullvad.mullvadvpn.viewmodel.Changelog
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import org.junit.Before
import org.junit.Rule
@@ -29,17 +28,17 @@ class ChangelogDialogTest {
}
@Test
- fun testShowChangeLogWhenNeeded() {
+ fun testShowChangelogWhenNeeded() {
// Arrange
- every { mockedViewModel.uiState } returns
- MutableStateFlow(ChangelogDialogUiState.Show(listOf(CHANGELOG_ITEM)))
- every { mockedViewModel.dismissChangelogDialog() } just Runs
+ every { mockedViewModel.markChangelogAsRead() } just Runs
composeTestRule.setContentWithTheme {
ChangelogDialog(
- changesList = listOf(CHANGELOG_ITEM),
- version = CHANGELOG_VERSION,
- onDismiss = { mockedViewModel.dismissChangelogDialog() }
+ Changelog(
+ changes = listOf(CHANGELOG_ITEM),
+ version = CHANGELOG_VERSION,
+ ),
+ onDismiss = { mockedViewModel.markChangelogAsRead() }
)
}
@@ -50,7 +49,7 @@ class ChangelogDialogTest {
composeTestRule.onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick()
// Assert
- verify { mockedViewModel.dismissChangelogDialog() }
+ verify { mockedViewModel.markChangelogAsRead() }
}
companion object {
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
index 56894addea..cd25c8ce0b 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
@@ -9,9 +9,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
@@ -21,12 +18,12 @@ import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
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.test.TOP_BAR_ACCOUNT_BUTTON
import net.mullvad.mullvadvpn.model.GeoIpLocation
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.relaylist.RelayItem
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
-import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.talpid.net.TransportProtocol
import net.mullvad.talpid.net.TunnelEndpoint
import net.mullvad.talpid.tunnel.ActionAfterDisconnect
@@ -57,7 +54,6 @@ class ConnectScreenTest {
composeTestRule.setContentWithTheme {
ConnectScreen(
uiState = ConnectUiState.INITIAL,
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -88,7 +84,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.TunnelStateBlocked
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -124,7 +119,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.TunnelStateBlocked
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -158,7 +152,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -191,7 +184,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -225,7 +217,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -259,7 +250,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -298,7 +288,6 @@ class ConnectScreenTest {
ErrorState(ErrorStateCause.StartTunnelError, true)
)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -338,7 +327,6 @@ class ConnectScreenTest {
ErrorState(ErrorStateCause.StartTunnelError, false)
)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -372,7 +360,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.TunnelStateBlocked
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -408,7 +395,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.TunnelStateBlocked
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -444,7 +430,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onSwitchLocationClick = mockedClickHandler
)
}
@@ -477,7 +462,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onDisconnectClick = mockedClickHandler
)
}
@@ -510,7 +494,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onReconnectClick = mockedClickHandler
)
}
@@ -542,7 +525,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onConnectClick = mockedClickHandler
)
}
@@ -574,7 +556,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onCancelClick = mockedClickHandler
)
}
@@ -607,7 +588,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onToggleTunnelInfo = mockedClickHandler
)
}
@@ -647,7 +627,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = null
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -686,7 +665,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.UpdateAvailable(versionInfo)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -723,7 +701,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.UnsupportedVersion(versionInfo)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -757,7 +734,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.AccountExpiry(expiryDate)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -796,7 +772,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.UnsupportedVersion(versionInfo)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -829,7 +804,6 @@ class ConnectScreenTest {
daysLeftUntilExpiry = null,
inAppNotification = InAppNotification.AccountExpiry(expiryDate)
),
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
@@ -842,34 +816,17 @@ class ConnectScreenTest {
@Test
fun testOpenAccountView() {
- // Arrange
- composeTestRule.setContentWithTheme {
- ConnectScreen(
- uiState = ConnectUiState.INITIAL,
- uiSideEffect =
- MutableStateFlow(
- ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser("222")
- )
- )
- }
- // Assert
- composeTestRule.apply { onNodeWithTag(SCROLLABLE_COLUMN_TEST_TAG).assertDoesNotExist() }
- }
+ val onAccountClickMockk: () -> Unit = mockk(relaxed = true)
- @Test
- fun testOpenOutOfTimeScreen() {
// Arrange
- val mockedOpenScreenHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
- ConnectScreen(
- uiState = ConnectUiState.INITIAL,
- uiSideEffect = MutableStateFlow(ConnectViewModel.UiSideEffect.OpenOutOfTimeView),
- onOpenOutOfTimeScreen = mockedOpenScreenHandler
- )
+ ConnectScreen(uiState = ConnectUiState.INITIAL, onAccountClick = onAccountClickMockk)
}
// Assert
- verify { mockedOpenScreenHandler.invoke() }
+ composeTestRule.onNodeWithTag(TOP_BAR_ACCOUNT_BUTTON).performClick()
+
+ verify(exactly = 1) { onAccountClickMockk() }
}
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt
index b5f762b89b..32fd727329 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt
@@ -6,7 +6,6 @@ import androidx.compose.ui.test.performClick
import io.mockk.MockKAnnotations
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.RelayFilterState
import net.mullvad.mullvadvpn.model.Ownership
@@ -31,8 +30,7 @@ class FilterScreenTest {
selectedOwnership = null,
selectedProviders = DUMMY_SELECTED_PROVIDERS,
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> }
+ onSelectedProvider = { _, _ -> }
)
}
composeTestRule.apply {
@@ -51,8 +49,7 @@ class FilterScreenTest {
selectedOwnership = null,
selectedProviders = DUMMY_SELECTED_PROVIDERS
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> }
+ onSelectedProvider = { _, _ -> }
)
}
composeTestRule.apply {
@@ -71,8 +68,7 @@ class FilterScreenTest {
selectedOwnership = Ownership.MullvadOwned,
selectedProviders = DUMMY_SELECTED_PROVIDERS
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> }
+ onSelectedProvider = { _, _ -> }
)
}
composeTestRule.apply {
@@ -91,8 +87,7 @@ class FilterScreenTest {
selectedOwnership = Ownership.Rented,
selectedProviders = DUMMY_SELECTED_PROVIDERS
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> }
+ onSelectedProvider = { _, _ -> }
)
}
composeTestRule.apply {
@@ -111,8 +106,7 @@ class FilterScreenTest {
selectedOwnership = null,
selectedProviders = DUMMY_SELECTED_PROVIDERS
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> }
+ onSelectedProvider = { _, _ -> }
)
}
@@ -135,8 +129,7 @@ class FilterScreenTest {
selectedOwnership = null,
selectedProviders = listOf(Provider("31173", true))
),
- uiCloseAction = MutableSharedFlow(),
- onSelectedProviders = { _, _ -> },
+ onSelectedProvider = { _, _ -> },
onApplyClick = mockClickListener
)
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
index 28e2519c81..d43a0931a1 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -9,9 +8,6 @@ import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
import net.mullvad.mullvadvpn.compose.state.PaymentState
@@ -20,10 +16,7 @@ import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
-import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.TunnelState
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
-import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -41,14 +34,11 @@ class OutOfTimeScreenTest {
// Arrange
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = false,
uiState = OutOfTimeUiState(deviceName = ""),
- uiSideEffect = MutableSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onDisconnectClick = {}
)
}
@@ -69,15 +59,11 @@ class OutOfTimeScreenTest {
// Arrange
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState(deviceName = ""),
- uiSideEffect =
- MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenAccountView("222")),
+ uiState = OutOfTimeUiState(deviceName = "", showSitePayment = true),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onDisconnectClick = {}
)
}
@@ -87,41 +73,16 @@ class OutOfTimeScreenTest {
}
@Test
- fun testOpenConnectScreen() {
- // Arrange
- val mockClickListener: () -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState(deviceName = ""),
- uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = mockClickListener,
- onDisconnectClick = {}
- )
- }
-
- // Assert
- verify(exactly = 1) { mockClickListener.invoke() }
- }
-
- @Test
fun testClickSitePaymentButton() {
// Arrange
val mockClickListener: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState(deviceName = ""),
- uiSideEffect = MutableSharedFlow(),
+ uiState = OutOfTimeUiState(deviceName = "", showSitePayment = true),
onSitePaymentClick = mockClickListener,
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onDisconnectClick = {}
)
}
@@ -139,14 +100,11 @@ class OutOfTimeScreenTest {
val mockClickListener: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState(deviceName = ""),
- uiSideEffect = MutableSharedFlow(),
+ uiState = OutOfTimeUiState(deviceName = "", showSitePayment = true),
onSitePaymentClick = {},
onRedeemVoucherClick = mockClickListener,
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onDisconnectClick = {}
)
}
@@ -164,18 +122,16 @@ class OutOfTimeScreenTest {
val mockClickListener: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
OutOfTimeUiState(
tunnelState = TunnelState.Connecting(null, null),
- deviceName = ""
+ deviceName = "",
+ showSitePayment = true
),
- uiSideEffect = MutableSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onDisconnectClick = mockClickListener
)
}
@@ -188,89 +144,20 @@ class OutOfTimeScreenTest {
}
@Test
- fun testShowPurchaseCompleteDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- OutOfTimeScreen(
- showSitePayment = true,
- uiState =
- OutOfTimeUiState(
- paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()
- ),
- uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> }
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Time was successfully added").assertExists()
- }
-
- @Test
- fun testShowVerificationErrorDialog() {
+ fun testShowBillingErrorPaymentButton() {
// Arrange
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
OutOfTimeUiState(
- paymentDialogData =
- PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
+ showSitePayment = true,
+ billingPaymentState = PaymentState.Error.Billing
),
- uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> }
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Verifying purchase").assertExists()
- }
-
- @Test
- fun testShowFetchProductsErrorDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- OutOfTimeScreen(
- showSitePayment = true,
- uiState =
- OutOfTimeUiState()
- .copy(
- paymentDialogData =
- PurchaseResult.Error.FetchProductsError(ProductId(""), null)
- .toPaymentDialogData()
- ),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
- }
-
- @Test
- fun testShowBillingErrorPaymentButton() {
- // Arrange
- composeTestRule.setContentWithTheme {
- OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState().copy(billingPaymentState = PaymentState.Error.Billing),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow(),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> }
+ onPurchaseBillingProductClick = { _ -> }
)
}
@@ -286,19 +173,17 @@ class OutOfTimeScreenTest {
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
OutOfTimeUiState(
+ showSitePayment = true,
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> }
+ onPurchaseBillingProductClick = { _ -> }
)
}
@@ -314,14 +199,12 @@ class OutOfTimeScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
- OutOfTimeUiState()
- .copy(
- billingPaymentState =
- PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
- ),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ OutOfTimeUiState(
+ showSitePayment = true,
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
)
}
@@ -335,28 +218,24 @@ class OutOfTimeScreenTest {
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
- OutOfTimeUiState()
- .copy(
- billingPaymentState =
- PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
- ),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ OutOfTimeUiState(
+ showSitePayment = true,
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ navigateToVerificationPendingDialog = mockNavigateToVerificationPending
)
}
// Act
composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
+ composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).assertExists()
- // Assert
- composeTestRule
- .onNodeWithText(
- "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
- )
- .assertExists()
+ verify(exactly = 1) { mockNavigateToVerificationPending.invoke() }
}
@Test
@@ -367,14 +246,12 @@ class OutOfTimeScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
- OutOfTimeUiState()
- .copy(
- billingPaymentState =
- PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
- ),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ OutOfTimeUiState(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct)),
+ showSitePayment = true,
+ )
)
}
@@ -385,25 +262,23 @@ class OutOfTimeScreenTest {
@Test
fun testOnPurchaseBillingProductClick() {
// Arrange
- val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val clickHandler: (ProductId) -> Unit = mockk(relaxed = true)
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
OutOfTimeUiState(
billingPaymentState =
- PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct)),
+ showSitePayment = true,
),
- uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onPurchaseBillingProductClick = clickHandler
)
}
@@ -412,6 +287,6 @@ class OutOfTimeScreenTest {
composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
// Assert
- verify { clickHandler(ProductId("PRODUCT_ID"), any()) }
+ verify { clickHandler(ProductId("PRODUCT_ID")) }
}
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt
index c07cb1aa6b..5a51a8f885 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt
@@ -8,6 +8,7 @@ import androidx.compose.ui.test.performTextInput
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
+import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.VoucherDialogState
import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState
@@ -30,7 +31,7 @@ class RedeemVoucherDialogTest {
// Arrange
val mockedClickHandler: (Boolean) -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState = VoucherDialogUiState.INITIAL,
onVoucherInputChange = {},
onRedeem = {},
@@ -50,7 +51,7 @@ class RedeemVoucherDialogTest {
// Arrange
val mockedClickHandler: (Boolean) -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState =
VoucherDialogUiState(voucherViewModelState = VoucherDialogState.Success(0)),
onVoucherInputChange = {},
@@ -71,7 +72,7 @@ class RedeemVoucherDialogTest {
// Arrange
val mockedClickHandler: (String) -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState = VoucherDialogUiState(),
onVoucherInputChange = mockedClickHandler,
onRedeem = {},
@@ -90,7 +91,7 @@ class RedeemVoucherDialogTest {
fun testVerifyingState() {
// Arrange
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState =
VoucherDialogUiState(voucherViewModelState = VoucherDialogState.Verifying),
onVoucherInputChange = {},
@@ -107,7 +108,7 @@ class RedeemVoucherDialogTest {
fun testSuccessState() {
// Arrange
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState =
VoucherDialogUiState(voucherViewModelState = VoucherDialogState.Success(0)),
onVoucherInputChange = {},
@@ -124,7 +125,7 @@ class RedeemVoucherDialogTest {
fun testErrorState() {
// Arrange
composeTestRule.setContentWithTheme {
- RedeemVoucherDialogScreen(
+ RedeemVoucherDialog(
uiState =
VoucherDialogUiState(
voucherViewModelState = VoucherDialogState.Error(ERROR_MESSAGE)
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt
index 7e66bc24d9..ea1d261689 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt
@@ -7,8 +7,6 @@ import androidx.compose.ui.test.performTextInput
import io.mockk.MockKAnnotations
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
@@ -39,8 +37,6 @@ class SelectLocationScreenTest {
composeTestRule.setContentWithTheme {
SelectLocationScreen(
uiState = SelectLocationUiState.Loading,
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -61,8 +57,6 @@ class SelectLocationScreenTest {
selectedProvidersCount = 0,
searchTerm = ""
),
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -101,8 +95,6 @@ class SelectLocationScreenTest {
selectedProvidersCount = 0,
searchTerm = ""
),
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
@@ -131,8 +123,6 @@ class SelectLocationScreenTest {
selectedProvidersCount = 0,
searchTerm = ""
),
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(),
onSearchTermInput = mockedSearchTermInput
)
}
@@ -160,8 +150,6 @@ class SelectLocationScreenTest {
selectedProvidersCount = 0,
searchTerm = mockSearchString
),
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(),
onSearchTermInput = mockedSearchTermInput
)
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
index 576660551e..e15ed012d6 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
@@ -4,8 +4,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import io.mockk.MockKAnnotations
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.SettingsUiState
import org.junit.Before
@@ -28,7 +26,6 @@ class SettingsScreenTest {
SettingsScreen(
uiState =
SettingsUiState(appVersion = "", isLoggedIn = true, isUpdateAvailable = true),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
// Assert
@@ -47,7 +44,6 @@ class SettingsScreenTest {
SettingsScreen(
uiState =
SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = true),
- enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
)
}
// Assert
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
index 9b6dd9e492..1ca2b3e1f7 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
@@ -1,7 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import androidx.compose.ui.test.assertIsEnabled
-import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
@@ -9,16 +7,11 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
-import androidx.compose.ui.test.performTextInput
import io.mockk.MockKAnnotations
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog
import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
-import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_LAST_ITEM_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG
@@ -32,7 +25,6 @@ import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.model.QuantumResistantState
import net.mullvad.mullvadvpn.onNodeWithTagAndText
import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
-import net.mullvad.mullvadvpn.viewmodel.StagedDns
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -51,7 +43,6 @@ class VpnSettingsScreenTest {
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState = VpnSettingsUiState.createDefault(),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -74,7 +65,6 @@ class VpnSettingsScreenTest {
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState = VpnSettingsUiState.createDefault(mtu = VALID_DUMMY_MTU_VALUE),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -87,166 +77,6 @@ class VpnSettingsScreenTest {
}
@Test
- fun testMtuClick() {
- // Arrange
- val mockedClickHandler: () -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState = VpnSettingsUiState.createDefault(),
- onMtuCellClick = mockedClickHandler,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- composeTestRule
- .onNodeWithTag(LAZY_LIST_TEST_TAG)
- .performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
-
- // Act
- composeTestRule.onNodeWithText("WireGuard MTU").performClick()
-
- // Assert
- verify { mockedClickHandler.invoke() }
- }
-
- @Test
- fun testMtuDialogWithDefaultValue() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING),
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(EMPTY_STRING).assertExists()
- }
-
- @Test
- fun testMtuDialogWithEditValue() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = VALID_DUMMY_MTU_VALUE)
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(VALID_DUMMY_MTU_VALUE).assertExists()
- }
-
- @Test
- fun testMtuDialogTextInput() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING)
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Act
- composeTestRule.onNodeWithText(EMPTY_STRING).performTextInput(VALID_DUMMY_MTU_VALUE)
-
- // Assert
- composeTestRule.onNodeWithText(VALID_DUMMY_MTU_VALUE).assertExists()
- }
-
- @Test
- fun testMtuDialogSubmitOfValidValue() {
- // Arrange
- val mockedSubmitHandler: (Int) -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = VALID_DUMMY_MTU_VALUE)
- ),
- onSaveMtuClick = mockedSubmitHandler,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Act
- composeTestRule.onNodeWithText("Submit").assertIsEnabled().performClick()
-
- // Assert
- verify { mockedSubmitHandler.invoke(VALID_DUMMY_MTU_VALUE.toInt()) }
- }
-
- @Test
- fun testMtuDialogSubmitButtonDisabledWhenInvalidInput() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = INVALID_DUMMY_MTU_VALUE)
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
- }
-
- @Test
- fun testMtuDialogResetClick() {
- // Arrange
- val mockedClickHandler: () -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING)
- ),
- onRestoreMtuClick = mockedClickHandler,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Act
- composeTestRule.onNodeWithText("Reset to default").performClick()
-
- // Assert
- verify { mockedClickHandler.invoke() }
- }
-
- @Test
- fun testMtuDialogCancelClick() {
- // Arrange
- val mockedClickHandler: () -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING)
- ),
- onCancelMtuDialogClick = mockedClickHandler,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Cancel").performClick()
-
- // Assert
- verify { mockedClickHandler.invoke() }
- }
-
- @Test
fun testCustomDnsAddressesAndAddButtonVisibleWhenCustomDnsEnabled() {
// Arrange
composeTestRule.setContentWithTheme {
@@ -254,7 +84,6 @@ class VpnSettingsScreenTest {
uiState =
VpnSettingsUiState.createDefault(
isCustomDnsEnabled = true,
- isAllowLanEnabled = false,
customDnsItems =
listOf(
CustomDnsItem(address = DUMMY_DNS_ADDRESS, false),
@@ -262,7 +91,6 @@ class VpnSettingsScreenTest {
CustomDnsItem(address = DUMMY_DNS_ADDRESS_3, false)
)
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -285,7 +113,6 @@ class VpnSettingsScreenTest {
isCustomDnsEnabled = false,
customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, false))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
composeTestRule
@@ -304,11 +131,10 @@ class VpnSettingsScreenTest {
uiState =
VpnSettingsUiState.createDefault(
isCustomDnsEnabled = true,
- isAllowLanEnabled = true,
+ isLocalNetworkSharingEnabled = true,
customDnsItems =
listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = true))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -324,11 +150,9 @@ class VpnSettingsScreenTest {
uiState =
VpnSettingsUiState.createDefault(
isCustomDnsEnabled = true,
- isAllowLanEnabled = false,
customDnsItems =
listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = false))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -344,11 +168,9 @@ class VpnSettingsScreenTest {
uiState =
VpnSettingsUiState.createDefault(
isCustomDnsEnabled = true,
- isAllowLanEnabled = true,
customDnsItems =
listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = false))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -364,11 +186,9 @@ class VpnSettingsScreenTest {
uiState =
VpnSettingsUiState.createDefault(
isCustomDnsEnabled = true,
- isAllowLanEnabled = false,
customDnsItems =
listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = true))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -379,228 +199,12 @@ class VpnSettingsScreenTest {
}
@Test
- fun testClickAddDns() {
- // Arrange
- val mockedClickHandler: (Int?) -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState = VpnSettingsUiState.createDefault(isCustomDnsEnabled = true),
- onDnsClick = mockedClickHandler,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Act
- composeTestRule.onNodeWithText("Add a server").performClick()
-
- // Assert
- verify { mockedClickHandler.invoke(null) }
- }
-
- @Test
- fun testShowDnsDialogForNewDnsServer() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false)
- ),
- )
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Add DNS server").assertExists()
- }
-
- @Test
- fun testShowDnsDialogForUpdatingDnsServer() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.EditDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false),
- index = 0
- )
- )
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Update DNS server").assertExists()
- }
-
- @Test
- fun testDnsDialogLanWarningShownWhenLanTrafficDisabledAndLocalAddressUsed() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = true),
- validationResult = StagedDns.ValidationResult.Success
- ),
- ),
- isAllowLanEnabled = false
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertExists()
- }
-
- @Test
- fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndLocalAddressUsed() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = true),
- validationResult = StagedDns.ValidationResult.Success
- ),
- ),
- isAllowLanEnabled = true
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
- }
-
- @Test
- fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndNonLocalAddressUsed() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false),
- validationResult = StagedDns.ValidationResult.Success
- ),
- ),
- isAllowLanEnabled = true
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
- }
-
- @Test
- fun testDnsDialogLanWarningNotShownWhenLanTrafficDisabledAndNonLocalAddressUsed() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false),
- validationResult = StagedDns.ValidationResult.Success
- ),
- ),
- isAllowLanEnabled = false
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist()
- }
-
- @Test
- fun testDnsDialogSubmitButtonDisabledOnInvalidDnsAddress() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false),
- validationResult = StagedDns.ValidationResult.InvalidAddress
- )
- )
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
- }
-
- @Test
- fun testDnsDialogSubmitButtonDisabledOnDuplicateDnsAddress() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.Dns(
- stagedDns =
- StagedDns.NewDns(
- item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false),
- validationResult =
- StagedDns.ValidationResult.DuplicateAddress
- )
- ),
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
- }
-
- @Test
fun testShowSelectedTunnelQuantumOption() {
// Arrange
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState =
VpnSettingsUiState.createDefault(quantumResistant = QuantumResistantState.On),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
composeTestRule
@@ -624,8 +228,7 @@ class VpnSettingsScreenTest {
VpnSettingsUiState.createDefault(
quantumResistant = QuantumResistantState.Auto,
),
- onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener
)
}
composeTestRule
@@ -642,23 +245,6 @@ class VpnSettingsScreenTest {
}
@Test
- fun testShowTunnelQuantumInfo() {
- // Arrange
- composeTestRule.setContentWithTheme {
- VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- dialog = VpnSettingsDialog.QuantumResistanceInfo
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Got it!").assertExists()
- }
-
- @Test
fun testShowWireguardPortOptions() {
// Arrange
composeTestRule.setContentWithTheme {
@@ -667,7 +253,6 @@ class VpnSettingsScreenTest {
VpnSettingsUiState.createDefault(
selectedWireguardPort = Constraint.Only(Port(53))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
@@ -698,8 +283,7 @@ class VpnSettingsScreenTest {
VpnSettingsUiState.createDefault(
selectedWireguardPort = Constraint.Only(Port(53))
),
- onWireguardPortSelected = mockSelectWireguardPortSelectionListener,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ onWireguardPortSelected = mockSelectWireguardPortSelectionListener
)
}
@@ -723,132 +307,163 @@ class VpnSettingsScreenTest {
}
@Test
- fun testShowWireguardPortInfo() {
+ fun testShowWireguardCustomPort() {
// Arrange
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState =
VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.WireguardPortInfo(
- availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121))
- )
+ customWireguardPort = Constraint.Only(Port(4000))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
)
}
- // Assert
+ // Act
composeTestRule
- .onNodeWithText(
- "The automatic setting will randomly choose from the valid port ranges shown below."
- )
- .assertExists()
+ .onNodeWithTag(LAZY_LIST_TEST_TAG)
+ .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
+
+ // Assert
+ composeTestRule.onNodeWithText("4000").assertExists()
}
@Test
- fun testShowWireguardCustomPortDialog() {
+ fun testSelectWireguardCustomPort() {
// Arrange
+ val onWireguardPortSelected: (Constraint<Port>) -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState =
VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.CustomPort(
- availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121))
- )
+ selectedWireguardPort = Constraint.Only(Port(4000)),
+ customWireguardPort = Constraint.Only(Port(4000))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ onWireguardPortSelected = onWireguardPortSelected
)
}
+ // Act
+ composeTestRule
+ .onNodeWithTag(LAZY_LIST_TEST_TAG)
+ .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
+ composeTestRule
+ .onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG)
+ .performClick()
+
// Assert
- composeTestRule.onNodeWithText("Valid ranges: 53, 120-121").assertExists()
+ verify { onWireguardPortSelected.invoke(Constraint.Only(Port(4000))) }
}
+ // Navigation Tests
+
@Test
- fun testShowWireguardCustomPort() {
+ fun testMtuClick() {
// Arrange
+ val mockedClickHandler: (Int?) -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(4000))
- ),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ uiState = VpnSettingsUiState.createDefault(),
+ navigateToMtuDialog = mockedClickHandler
)
}
- // Act
composeTestRule
.onNodeWithTag(LAZY_LIST_TEST_TAG)
- .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
+ .performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
+
+ // Act
+ composeTestRule.onNodeWithText("WireGuard MTU").performClick()
// Assert
- composeTestRule.onNodeWithText("4000").assertExists()
+ verify { mockedClickHandler.invoke(null) }
}
@Test
- fun testClickWireguardCustomPortMainCell() {
+ fun testClickAddDns() {
+ // Arrange
+ val mockedClickHandler: (Int?, String?) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContentWithTheme {
+ VpnSettingsScreen(
+ uiState = VpnSettingsUiState.createDefault(isCustomDnsEnabled = true),
+ navigateToDns = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Add a server").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke(null, null) }
+ }
+
+ @Test
+ fun testShowTunnelQuantumInfo() {
+ val mockedShowTunnelQuantumInfoClick: () -> Unit = mockk(relaxed = true)
+
// Arrange
- val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState = VpnSettingsUiState.createDefault(),
- onShowCustomPortDialog = mockOnShowCustomPortDialog,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ navigateToQuantumResistanceInfo = mockedShowTunnelQuantumInfoClick
)
}
// Act
composeTestRule
.onNodeWithTag(LAZY_LIST_TEST_TAG)
- .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
- composeTestRule.onNodeWithTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG).performClick()
+ .performScrollToNode(hasTestTag(LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG))
+ composeTestRule.onNodeWithText("Quantum-resistant tunnel").performClick()
// Assert
- verify { mockOnShowCustomPortDialog.invoke() }
+ verify(exactly = 1) { mockedShowTunnelQuantumInfoClick() }
}
@Test
- fun testClickWireguardCustomPortNumberCell() {
+ fun testShowWireguardPortInfo() {
+ val mockedClickHandler: (List<PortRange>) -> Unit = mockk(relaxed = true)
+
// Arrange
- val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(4000))
- ),
- onShowCustomPortDialog = mockOnShowCustomPortDialog,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ uiState = VpnSettingsUiState.createDefault(),
+ navigateToWireguardPortInfo = mockedClickHandler
+ )
+ }
+
+ composeTestRule.onNodeWithText("WireGuard port").performClick()
+
+ verify(exactly = 1) { mockedClickHandler.invoke(any()) }
+ }
+
+ @Test
+ fun testShowWireguardCustomPortDialog() {
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ VpnSettingsScreen(
+ uiState = VpnSettingsUiState.createDefault(),
+ navigateToWireguardPortDialog = mockedClickHandler
)
}
- // Act
composeTestRule
.onNodeWithTag(LAZY_LIST_TEST_TAG)
- .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
- composeTestRule
- .onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG)
- .performClick()
+ .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG))
+ composeTestRule.onNodeWithText("Custom").performClick()
// Assert
- verify { mockOnShowCustomPortDialog.invoke() }
+ verify(exactly = 1) { mockedClickHandler.invoke() }
}
@Test
- fun testSelectWireguardCustomPort() {
+ fun testClickWireguardCustomPortMainCell() {
// Arrange
- val onWireguardPortSelected: (Constraint<Port>) -> Unit = mockk(relaxed = true)
+ val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
- uiState =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(4000))
- ),
- onWireguardPortSelected = onWireguardPortSelected,
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ uiState = VpnSettingsUiState.createDefault(),
+ navigateToWireguardPortDialog = mockOnShowCustomPortDialog
)
}
@@ -856,51 +471,43 @@ class VpnSettingsScreenTest {
composeTestRule
.onNodeWithTag(LAZY_LIST_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
- composeTestRule
- .onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG)
- .performClick()
+ composeTestRule.onNodeWithTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG).performClick()
// Assert
- verify { onWireguardPortSelected.invoke(Constraint.Only(Port(4000))) }
+ verify { mockOnShowCustomPortDialog.invoke() }
}
@Test
- fun testShowWireguardCustomPortDialogInvalidInt() {
- // Input a number to make sure that a too long number does not show and it does not crash
- // the app
-
+ fun testClickWireguardCustomPortNumberCell() {
// Arrange
+ val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
VpnSettingsScreen(
uiState =
VpnSettingsUiState.createDefault(
- dialog =
- VpnSettingsDialog.CustomPort(
- availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121))
- )
+ selectedWireguardPort = Constraint.Only(Port(4000))
),
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow()
+ navigateToWireguardPortDialog = mockOnShowCustomPortDialog
)
}
// Act
composeTestRule
- .onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG)
- .performTextInput("21474836471")
+ .onNodeWithTag(LAZY_LIST_TEST_TAG)
+ .performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
+ composeTestRule
+ .onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG)
+ .performClick()
// Assert
- composeTestRule
- .onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, "21474836471")
- .assertDoesNotExist()
+ verify { mockOnShowCustomPortDialog.invoke() }
}
companion object {
private const val LOCAL_DNS_SERVER_WARNING =
"The local DNS server will not work unless you enable " +
"\"Local Network Sharing\" under Preferences."
- private const val EMPTY_STRING = ""
private const val VALID_DUMMY_MTU_VALUE = "1337"
- private const val INVALID_DUMMY_MTU_VALUE = "1111"
private const val DUMMY_DNS_ADDRESS = "0.0.0.1"
private const val DUMMY_DNS_ADDRESS_2 = "0.0.0.2"
private const val DUMMY_DNS_ADDRESS_3 = "0.0.0.3"
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt
index a54c41c20d..e62b1a399b 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -9,9 +8,6 @@ import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
@@ -20,9 +16,6 @@ import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
-import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
-import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -40,16 +33,14 @@ class WelcomeScreenTest {
// Arrange
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState = WelcomeUiState(),
- uiSideEffect = MutableSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ navigateToDeviceInfoDialog = {},
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {}
)
}
@@ -65,16 +56,14 @@ class WelcomeScreenTest {
// Arrange
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = false,
uiState = WelcomeUiState(),
- uiSideEffect = MutableSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ navigateToDeviceInfoDialog = {},
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {}
)
}
@@ -96,16 +85,14 @@ class WelcomeScreenTest {
val expectedAccountNumber = "1111 2222 3333 4444"
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState = WelcomeUiState(accountNumber = rawAccountNumber),
- uiSideEffect = MutableSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToDeviceInfoDialog = {},
+ navigateToVerificationPendingDialog = {}
)
}
@@ -114,67 +101,19 @@ class WelcomeScreenTest {
}
@Test
- fun testOpenAccountView() {
- // Arrange
- composeTestRule.setContentWithTheme {
- WelcomeScreen(
- showSitePayment = true,
- uiState = WelcomeUiState(),
- uiSideEffect =
- MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenAccountView("222")),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
- )
- }
-
- // Assert
- composeTestRule.apply { onNodeWithText("Congrats!").assertDoesNotExist() }
- }
-
- @Test
- fun testOpenConnectScreen() {
- // Arrange
- val mockClickListener: () -> Unit = mockk(relaxed = true)
- composeTestRule.setContentWithTheme {
- WelcomeScreen(
- showSitePayment = true,
- uiState = WelcomeUiState(),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = mockClickListener,
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
- )
- }
-
- // Assert
- verify(exactly = 1) { mockClickListener.invoke() }
- }
-
- @Test
fun testClickSitePaymentButton() {
// Arrange
val mockClickListener: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
- uiState = WelcomeUiState(),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
+ uiState = WelcomeUiState(showSitePayment = true),
onSitePaymentClick = mockClickListener,
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -191,16 +130,14 @@ class WelcomeScreenTest {
val mockClickListener: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState = WelcomeUiState(),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = mockClickListener,
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -212,99 +149,18 @@ class WelcomeScreenTest {
}
@Test
- fun testShowPurchaseCompleteDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- WelcomeScreen(
- showSitePayment = true,
- uiState =
- WelcomeUiState(
- paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()
- ),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Time was successfully added").assertExists()
- }
-
- @Test
- fun testShowVerificationErrorDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- WelcomeScreen(
- showSitePayment = true,
- uiState =
- WelcomeUiState(
- paymentDialogData =
- PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
- ),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Verifying purchase").assertExists()
- }
-
- @Test
- fun testShowFetchProductsErrorDialog() {
- // Arrange
- composeTestRule.setContentWithTheme {
- WelcomeScreen(
- showSitePayment = true,
- uiState =
- WelcomeUiState()
- .copy(
- paymentDialogData =
- PurchaseResult.Error.FetchProductsError(ProductId(""), null)
- .toPaymentDialogData()
- ),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
- onSitePaymentClick = {},
- onRedeemVoucherClick = {},
- onSettingsClick = {},
- onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
- )
- }
-
- // Assert
- composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
- }
-
- @Test
fun testShowBillingErrorPaymentButton() {
// Arrange
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState = WelcomeUiState().copy(billingPaymentState = PaymentState.Error.Billing),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onClosePurchaseResultDialog = {},
- onPurchaseBillingProductClick = { _, _ -> }
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -320,20 +176,18 @@ class WelcomeScreenTest {
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState(
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -349,21 +203,19 @@ class WelcomeScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState()
.copy(
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -377,23 +229,22 @@ class WelcomeScreenTest {
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ val mockShowPendingInfo = mockk<() -> Unit>(relaxed = true)
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState()
.copy(
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = mockShowPendingInfo,
+ navigateToDeviceInfoDialog = {}
)
}
@@ -401,11 +252,7 @@ class WelcomeScreenTest {
composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
// Assert
- composeTestRule
- .onNodeWithText(
- "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
- )
- .assertExists()
+ verify(exactly = 1) { mockShowPendingInfo() }
}
@Test
@@ -416,21 +263,19 @@ class WelcomeScreenTest {
every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState()
.copy(
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -441,27 +286,25 @@ class WelcomeScreenTest {
@Test
fun testOnPurchaseBillingProductClick() {
// Arrange
- val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val clickHandler: (ProductId) -> Unit = mockk(relaxed = true)
val mockPaymentProduct: PaymentProduct = mockk()
every { mockPaymentProduct.price } returns ProductPrice("$10")
every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
every { mockPaymentProduct.status } returns null
composeTestRule.setContentWithTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState(
billingPaymentState =
PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
),
- uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
onPurchaseBillingProductClick = clickHandler,
- onClosePurchaseResultDialog = {}
+ navigateToVerificationPendingDialog = {},
+ navigateToDeviceInfoDialog = {}
)
}
@@ -469,6 +312,6 @@ class WelcomeScreenTest {
composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
// Assert
- verify { clickHandler(ProductId("PRODUCT_ID"), any()) }
+ verify { clickHandler(ProductId("PRODUCT_ID")) }
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
index 8219aa9984..cd5a08edbf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
@@ -37,8 +37,8 @@ import net.mullvad.mullvadvpn.lib.theme.color.selected
private fun PreviewCustomPortCell() {
AppTheme {
SpacedColumn(Modifier.background(MaterialTheme.colorScheme.background)) {
- CustomPortCell(title = "Title", isSelected = true, port = "444")
- CustomPortCell(title = "Title", isSelected = false, port = "")
+ CustomPortCell(title = "Title", isSelected = true, port = 444)
+ CustomPortCell(title = "Title", isSelected = false, port = null)
}
}
}
@@ -47,7 +47,7 @@ private fun PreviewCustomPortCell() {
fun CustomPortCell(
title: String,
isSelected: Boolean,
- port: String,
+ port: Int?,
mainTestTag: String = "",
numberTestTag: String = "",
onMainCellClicked: () -> Unit = {},
@@ -100,7 +100,7 @@ fun CustomPortCell(
.testTag(numberTestTag)
) {
Text(
- text = port.ifEmpty { stringResource(id = R.string.port) },
+ text = port?.toString() ?: stringResource(id = R.string.port),
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center)
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
index 4d6fb89834..2a0043842a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
@@ -1,12 +1,10 @@
package net.mullvad.mullvadvpn.compose.cell
-import androidx.compose.foundation.layout.wrapContentHeight
-import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
@@ -54,12 +52,12 @@ fun DnsCell(
}
@Composable
-private fun DnsTitle(address: String, modifier: Modifier = Modifier) {
+private fun RowScope.DnsTitle(address: String, modifier: Modifier = Modifier) {
Text(
text = address,
color = Color.White,
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Start,
- modifier = modifier.wrapContentWidth(align = Alignment.End).wrapContentHeight()
+ modifier = modifier.weight(1f)
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt
index 6566a9f30e..d2dcf1e863 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt
@@ -47,7 +47,6 @@ fun FilterCell(
Modifier.horizontalScroll(scrollState)
.padding(
horizontal = Dimens.searchFieldHorizontalPadding,
- vertical = Dimens.selectLocationTitlePadding
)
.fillMaxWidth(),
) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt
index 3388eb2b85..32c9f83a33 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt
@@ -10,24 +10,25 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.AnimatedIconButton
-import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
import net.mullvad.mullvadvpn.lib.theme.AppTheme
-import net.mullvad.mullvadvpn.ui.extension.copyToClipboard
@Preview
@Composable
private fun PreviewCopyableObfuscationView() {
- AppTheme { CopyableObfuscationView("1111222233334444", modifier = Modifier.fillMaxWidth()) }
+ AppTheme { CopyableObfuscationView("1111222233334444", {}, modifier = Modifier.fillMaxWidth()) }
}
@Composable
-fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) {
+fun CopyableObfuscationView(
+ content: String,
+ onCopyClicked: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
var obfuscationEnabled by remember { mutableStateOf(true) }
Row(verticalAlignment = CenterVertically, modifier = modifier) {
@@ -44,19 +45,7 @@ fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) {
onClick = { obfuscationEnabled = !obfuscationEnabled }
)
- val context = LocalContext.current
- val copy = {
- context.copyToClipboard(
- content = content,
- clipboardLabel = context.getString(R.string.mullvad_account_number)
- )
- SdkUtils.showCopyToastIfNeeded(
- context,
- context.getString(R.string.copied_mullvad_account_number)
- )
- }
-
- CopyAnimatedIconButton(onClick = copy)
+ CopyAnimatedIconButton(onClick = { onCopyClicked(content) })
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
index ce8507db64..9a35df1ad3 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
@@ -19,33 +19,25 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
@Composable
fun ScaffoldWithTopBar(
topBarColor: Color,
- statusBarColor: Color,
- navigationBarColor: Color,
modifier: Modifier = Modifier,
iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
onSettingsClicked: (() -> Unit)?,
onAccountClicked: (() -> Unit)?,
isIconAndLogoVisible: Boolean = true,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ enabled: Boolean = true,
content: @Composable (PaddingValues) -> Unit,
) {
- val systemUiController = rememberSystemUiController()
- LaunchedEffect(key1 = statusBarColor, key2 = navigationBarColor) {
- systemUiController.setStatusBarColor(statusBarColor)
- systemUiController.setNavigationBarColor(navigationBarColor)
- }
Scaffold(
modifier = modifier,
@@ -55,7 +47,8 @@ fun ScaffoldWithTopBar(
iconTintColor = iconTintColor,
onSettingsClicked = onSettingsClicked,
onAccountClicked = onAccountClicked,
- isIconAndLogoVisible = isIconAndLogoVisible
+ isIconAndLogoVisible = isIconAndLogoVisible,
+ enabled = enabled,
)
},
snackbarHost = {
@@ -71,8 +64,6 @@ fun ScaffoldWithTopBar(
@Composable
fun ScaffoldWithTopBarAndDeviceName(
topBarColor: Color,
- statusBarColor: Color,
- navigationBarColor: Color?,
modifier: Modifier = Modifier,
iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
onSettingsClicked: (() -> Unit)?,
@@ -83,14 +74,6 @@ fun ScaffoldWithTopBarAndDeviceName(
timeLeft: Int?,
content: @Composable (PaddingValues) -> Unit,
) {
- val systemUiController = rememberSystemUiController()
- LaunchedEffect(key1 = statusBarColor, key2 = navigationBarColor) {
- systemUiController.setStatusBarColor(statusBarColor)
- if (navigationBarColor != null) {
- systemUiController.setNavigationBarColor(navigationBarColor)
- }
- }
-
Scaffold(
modifier = modifier,
topBar = {
@@ -130,6 +113,7 @@ fun ScaffoldWithMediumTopBar(
actions: @Composable RowScope.() -> Unit = {},
lazyListState: LazyListState = rememberLazyListState(),
scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit
) {
@@ -147,6 +131,12 @@ fun ScaffoldWithMediumTopBar(
scrollBehavior = scrollBehavior
)
},
+ snackbarHost = {
+ SnackbarHost(
+ snackbarHostState,
+ snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }
+ )
+ },
content = {
content(
Modifier.fillMaxSize()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
index babd89271c..73bec5f14f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
@@ -33,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -40,6 +41,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON
+import net.mullvad.mullvadvpn.compose.test.TOP_BAR_SETTINGS_BUTTON
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
@@ -104,6 +107,7 @@ fun MullvadTopBar(
onSettingsClicked: (() -> Unit)?,
onAccountClicked: (() -> Unit)?,
modifier: Modifier = Modifier,
+ enabled: Boolean = true,
iconTintColor: Color,
isIconAndLogoVisible: Boolean = true
) {
@@ -149,7 +153,11 @@ fun MullvadTopBar(
},
actions = {
if (onAccountClicked != null) {
- IconButton(onClick = onAccountClicked) {
+ IconButton(
+ modifier = Modifier.testTag(TOP_BAR_ACCOUNT_BUTTON),
+ enabled = enabled,
+ onClick = onAccountClicked
+ ) {
Icon(
painter = painterResource(R.drawable.icon_account),
tint = iconTintColor,
@@ -159,7 +167,11 @@ fun MullvadTopBar(
}
if (onSettingsClicked != null) {
- IconButton(onClick = onSettingsClicked) {
+ IconButton(
+ modifier = Modifier.testTag(TOP_BAR_SETTINGS_BUTTON),
+ enabled = enabled,
+ onClick = onSettingsClicked
+ ) {
Icon(
painter = painterResource(R.drawable.icon_settings),
tint = iconTintColor,
@@ -274,6 +286,7 @@ fun MullvadTopBarWithDeviceName(
onSettingsClicked,
onAccountClicked,
Modifier,
+ enabled = true,
iconTintColor,
isIconAndLogoVisible,
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
index 9ce21c6bac..8e34ecdce4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
@@ -16,18 +16,38 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation.NavController
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.Changelog
+import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
+import org.koin.androidx.compose.koinViewModel
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun ChangelogDialog(changesList: List<String>, version: String, onDismiss: () -> Unit) {
+fun Changelog(navController: NavController, changeLog: Changelog) {
+ val viewModel = koinViewModel<ChangelogViewModel>()
+
+ ChangelogDialog(
+ changeLog,
+ onDismiss = {
+ viewModel.markChangelogAsRead()
+ navController.navigateUp()
+ }
+ )
+}
+
+@Composable
+fun ChangelogDialog(changeLog: Changelog, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
- text = version,
+ text = changeLog.version,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
@@ -46,7 +66,7 @@ fun ChangelogDialog(changesList: List<String>, version: String, onDismiss: () ->
modifier = Modifier.fillMaxWidth()
)
- changesList.forEach { changeItem -> ChangeListItem(text = changeItem) }
+ changeLog.changes.forEach { changeItem -> ChangeListItem(text = changeItem) }
}
},
confirmButton = {
@@ -80,7 +100,9 @@ private fun ChangeListItem(text: String) {
@Preview
@Composable
private fun PreviewChangelogDialogWithSingleShortItem() {
- AppTheme { ChangelogDialog(changesList = listOf("Item 1"), version = "1111.1", onDismiss = {}) }
+ AppTheme {
+ ChangelogDialog(Changelog(changes = listOf("Item 1"), version = "1111.1"), onDismiss = {})
+ }
}
@Preview
@@ -93,8 +115,7 @@ private fun PreviewChangelogDialogWithTwoLongItems() {
AppTheme {
ChangelogDialog(
- changesList = listOf(longPreviewText, longPreviewText),
- version = "1111.1",
+ Changelog(changes = listOf(longPreviewText, longPreviewText), version = "1111.1"),
onDismiss = {}
)
}
@@ -105,20 +126,22 @@ private fun PreviewChangelogDialogWithTwoLongItems() {
private fun PreviewChangelogDialogWithTenShortItems() {
AppTheme {
ChangelogDialog(
- changesList =
- listOf(
- "Item 1",
- "Item 2",
- "Item 3",
- "Item 4",
- "Item 5",
- "Item 6",
- "Item 7",
- "Item 8",
- "Item 9",
- "Item 10"
- ),
- version = "1111.1",
+ Changelog(
+ changes =
+ listOf(
+ "Item 1",
+ "Item 2",
+ "Item 3",
+ "Item 4",
+ "Item 5",
+ "Item 6",
+ "Item 7",
+ "Item 8",
+ "Item 9",
+ "Item 10"
+ ),
+ version = "1111.1"
+ ),
onDismiss = {}
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt
index 29a57ed331..145208ce16 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt
@@ -2,11 +2,15 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.textResource
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun ContentBlockersInfoDialog(onDismiss: () -> Unit) {
+fun ContentBlockersInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message =
buildString {
@@ -20,6 +24,6 @@ fun ContentBlockersInfoDialog(onDismiss: () -> Unit) {
stringResource(id = R.string.settings_changes_effect_warning_content_blocker)
)
},
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt
index cf9233ec94..f58768d0c6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt
@@ -3,18 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
@Preview
@Composable
private fun PreviewCustomDnsInfoDialog() {
- CustomDnsInfoDialog(onDismiss = {})
+ CustomDnsInfoDialog(EmptyDestinationsNavigator)
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun CustomDnsInfoDialog(onDismiss: () -> Unit) {
+fun CustomDnsInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message = stringResource(id = R.string.settings_changes_effect_warning_content_blocker),
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt
index 39e82bc57d..0e1c315959 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt
@@ -2,10 +2,14 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun DeviceNameInfoDialog(onDismiss: () -> Unit) {
+fun DeviceNameInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message =
buildString {
@@ -15,6 +19,6 @@ fun DeviceNameInfoDialog(onDismiss: () -> Unit) {
appendLine()
append(stringResource(id = R.string.device_name_info_third_paragraph))
},
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
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 527fcf8738..7de79207e1 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,32 +8,45 @@ 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.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.textfield.DnsTextField
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.MullvadRed
-import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
-import net.mullvad.mullvadvpn.viewmodel.StagedDns
+import net.mullvad.mullvadvpn.viewmodel.DnsDialogSideEffect
+import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
@Preview
@Composable
private fun PreviewDnsDialogNew() {
AppTheme {
DnsDialog(
- stagedDns =
- StagedDns.NewDns(CustomDnsItem.default(), StagedDns.ValidationResult.Success),
- isAllowLanEnabled = true,
- onIpAddressChanged = {},
- onAttemptToSave = {},
- onRemove = {},
- onDismiss = {}
+ DnsDialogViewState(
+ "1.1.1.1",
+ DnsDialogViewState.ValidationResult.Success,
+ false,
+ false,
+ true
+ ),
+ {},
+ {},
+ {},
+ {}
)
}
}
@@ -43,17 +56,17 @@ private fun PreviewDnsDialogNew() {
private fun PreviewDnsDialogEdit() {
AppTheme {
DnsDialog(
- stagedDns =
- StagedDns.EditDns(
- CustomDnsItem("1.1.1.1", false),
- StagedDns.ValidationResult.Success,
- 0
- ),
- isAllowLanEnabled = true,
- onIpAddressChanged = {},
- onAttemptToSave = {},
- onRemove = {},
- onDismiss = {}
+ DnsDialogViewState(
+ "1.1.1.1",
+ DnsDialogViewState.ValidationResult.Success,
+ false,
+ false,
+ false
+ ),
+ {},
+ {},
+ {},
+ {}
)
}
}
@@ -63,35 +76,62 @@ private fun PreviewDnsDialogEdit() {
private fun PreviewDnsDialogEditAllowLanDisabled() {
AppTheme {
DnsDialog(
- stagedDns =
- StagedDns.EditDns(
- CustomDnsItem(address = "1.1.1.1", isLocal = true),
- StagedDns.ValidationResult.Success,
- 0
- ),
- isAllowLanEnabled = false,
- onIpAddressChanged = {},
- onAttemptToSave = {},
- onRemove = {},
- onDismiss = {}
+ DnsDialogViewState(
+ "192.168.1.1",
+ DnsDialogViewState.ValidationResult.Success,
+ true,
+ false,
+ true
+ ),
+ {},
+ {},
+ {},
+ {}
)
}
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
fun DnsDialog(
- stagedDns: StagedDns,
- isAllowLanEnabled: Boolean,
- onIpAddressChanged: (String) -> Unit,
- onAttemptToSave: () -> Unit,
- onRemove: () -> Unit,
+ resultNavigator: ResultBackNavigator<Boolean>,
+ index: Int?,
+ initialValue: String?,
+) {
+ val viewModel =
+ koinViewModel<DnsDialogViewModel>(parameters = { parametersOf(initialValue, index) })
+
+ LaunchedEffect(Unit) {
+ viewModel.uiSideEffect.collect {
+ when (it) {
+ DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true)
+ }
+ }
+ }
+ val state by viewModel.uiState.collectAsState(null)
+
+ DnsDialog(
+ state ?: return,
+ viewModel::onDnsInputChange,
+ onSaveDnsClick = viewModel::onSaveDnsClick,
+ onRemoveDnsClick = viewModel::onRemoveDnsClick,
+ onDismiss = { resultNavigator.navigateBack(false) }
+ )
+}
+
+@Composable
+fun DnsDialog(
+ state: DnsDialogViewState,
+ onDnsInputChange: (String) -> Unit,
+ onSaveDnsClick: () -> Unit,
+ onRemoveDnsClick: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
title = {
Text(
text =
- if (stagedDns is StagedDns.NewDns) {
+ if (state.isNewEntry) {
stringResource(R.string.add_dns_server_dialog_title)
} else {
stringResource(R.string.update_dns_server_dialog_title)
@@ -103,10 +143,10 @@ fun DnsDialog(
text = {
Column {
DnsTextField(
- value = stagedDns.item.address,
- isValidValue = stagedDns.isValid(),
- onValueChanged = { newMtuValue -> onIpAddressChanged(newMtuValue) },
- onSubmit = { onAttemptToSave() },
+ value = state.ipAddress,
+ isValidValue = state.isValid(),
+ onValueChanged = { newDnsValue -> onDnsInputChange(newDnsValue) },
+ onSubmit = onSaveDnsClick,
isEnabled = true,
placeholderText = stringResource(R.string.custom_dns_hint),
modifier = Modifier.fillMaxWidth()
@@ -114,11 +154,11 @@ fun DnsDialog(
val errorMessage =
when {
- stagedDns.validationResult is
- StagedDns.ValidationResult.DuplicateAddress -> {
+ state.validationResult is
+ DnsDialogViewState.ValidationResult.DuplicateAddress -> {
stringResource(R.string.duplicate_address_warning)
}
- stagedDns.item.isLocal && isAllowLanEnabled.not() -> {
+ state.isLocal && !state.isAllowLanEnabled -> {
stringResource(id = R.string.confirm_local_dns)
}
else -> {
@@ -140,15 +180,15 @@ fun DnsDialog(
Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
- onClick = onAttemptToSave,
- isEnabled = stagedDns.isValid(),
+ onClick = onSaveDnsClick,
+ isEnabled = state.isValid(),
text = stringResource(id = R.string.submit_button),
)
- if (stagedDns is StagedDns.EditDns) {
+ if (!state.isNewEntry) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
- onClick = onRemove,
+ onClick = onRemoveDnsClick,
text = stringResource(id = R.string.remove_button)
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt
index 983d0c1e04..ebe46b6050 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt
@@ -3,17 +3,22 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.textResource
@Preview
@Composable
private fun PreviewLocalNetworkSharingInfoDialog() {
- LocalNetworkSharingInfoDialog(onDismiss = {})
+ LocalNetworkSharingInfoDialog(EmptyDestinationsNavigator)
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun LocalNetworkSharingInfoDialog(onDismiss: () -> Unit) {
+fun LocalNetworkSharingInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message = stringResource(id = R.string.local_network_sharing_info),
additionalInfo =
@@ -21,6 +26,6 @@ fun LocalNetworkSharingInfoDialog(onDismiss: () -> Unit) {
appendLine(stringResource(id = R.string.local_network_sharing_additional_info))
appendLine(textResource(id = R.string.local_network_sharing_ip_ranges))
},
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt
index 378e95c98e..1f627be040 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt
@@ -3,15 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
@Preview
@Composable
private fun PreviewMalwareInfoDialog() {
- MalwareInfoDialog(onDismiss = {})
+ MalwareInfoDialog(EmptyDestinationsNavigator)
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun MalwareInfoDialog(onDismiss: () -> Unit) {
- InfoDialog(message = stringResource(id = R.string.malware_info), onDismiss = onDismiss)
+fun MalwareInfoDialog(navigator: DestinationsNavigator) {
+ InfoDialog(
+ message = stringResource(id = R.string.malware_info),
+ onDismiss = navigator::navigateUp
+ )
}
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 bc28169bb2..d0d8da8b57 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,11 +8,16 @@ 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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+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
@@ -22,24 +27,45 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
import net.mullvad.mullvadvpn.util.isValidMtu
+import net.mullvad.mullvadvpn.viewmodel.MtuDialogSideEffect
+import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
private fun PreviewMtuDialog() {
- AppTheme {
- MtuDialog(mtuInitial = 1234, onSave = {}, onRestoreDefaultValue = {}, onDismiss = {})
+ AppTheme { MtuDialog(mtuInitial = 1234, EmptyDestinationsNavigator) }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun MtuDialog(mtuInitial: Int?, navigator: DestinationsNavigator) {
+ val viewModel = koinViewModel<MtuDialogViewModel>()
+
+ LaunchedEffect(Unit) {
+ viewModel.uiSideEffect.collect {
+ when (it) {
+ MtuDialogSideEffect.Complete -> navigator.navigateUp()
+ }
+ }
}
+ MtuDialog(
+ mtuInitial = mtuInitial,
+ onSaveMtu = viewModel::onSaveClick,
+ onResetMtu = viewModel::onRestoreClick,
+ onDismiss = navigator::navigateUp
+ )
}
@Composable
fun MtuDialog(
mtuInitial: Int?,
- onSave: (Int) -> Unit,
- onRestoreDefaultValue: () -> Unit,
+ onSaveMtu: (Int) -> Unit,
+ onResetMtu: () -> Unit,
onDismiss: () -> Unit,
) {
- val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") }
+ val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") }
val isValidMtu = mtu.value.toIntOrNull()?.isValidMtu() == true
AlertDialog(
@@ -59,7 +85,7 @@ fun MtuDialog(
onSubmit = { newMtuValue ->
val mtuInt = newMtuValue.toIntOrNull()
if (mtuInt?.isValidMtu() == true) {
- onSave(mtuInt)
+ onSaveMtu(mtuInt)
}
},
isEnabled = true,
@@ -91,7 +117,7 @@ fun MtuDialog(
onClick = {
val mtuInt = mtu.value.toIntOrNull()
if (mtuInt?.isValidMtu() == true) {
- onSave(mtuInt)
+ onSaveMtu(mtuInt)
}
}
)
@@ -99,7 +125,7 @@ fun MtuDialog(
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.reset_to_default_button),
- onClick = onRestoreDefaultValue
+ onClick = onResetMtu
)
PrimaryButton(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt
index f54eabdbaf..cf4db26e2e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt
@@ -3,15 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
@Preview
@Composable
private fun PreviewObfuscationInfoDialog() {
- ObfuscationInfoDialog(onDismiss = {})
+ ObfuscationInfoDialog(EmptyDestinationsNavigator)
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun ObfuscationInfoDialog(onDismiss: () -> Unit) {
- InfoDialog(message = stringResource(id = R.string.obfuscation_info), onDismiss = onDismiss)
+fun ObfuscationInfoDialog(navigator: DestinationsNavigator) {
+ InfoDialog(
+ message = stringResource(id = R.string.obfuscation_info),
+ onDismiss = navigator::navigateUp
+ )
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt
index 3a20e9c805..e7773ed0a3 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt
@@ -3,19 +3,24 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
@Preview
@Composable
private fun PreviewQuantumResistanceInfoDialog() {
- QuantumResistanceInfoDialog(onDismiss = {})
+ QuantumResistanceInfoDialog(EmptyDestinationsNavigator)
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun QuantumResistanceInfoDialog(onDismiss: () -> Unit) {
+fun QuantumResistanceInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message = stringResource(id = R.string.quantum_resistant_info_first_paragaph),
additionalInfo = stringResource(id = R.string.quantum_resistant_info_second_paragaph),
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
index 1c48a8a64a..15d8e9f3c7 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
@@ -12,6 +12,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@@ -23,6 +24,9 @@ import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
@@ -38,7 +42,9 @@ import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel
import org.joda.time.DateTimeConstants
+import org.koin.androidx.compose.koinViewModel
@Preview(device = Devices.TV_720p)
@Composable
@@ -92,6 +98,18 @@ private fun PreviewRedeemVoucherDialogSuccess() {
}
}
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun RedeemVoucher(resultBackNavigator: ResultBackNavigator<Boolean>) {
+ val vm = koinViewModel<VoucherDialogViewModel>()
+ RedeemVoucherDialog(
+ uiState = vm.uiState.collectAsState().value,
+ onVoucherInputChange = vm::onVoucherInputChange,
+ onRedeem = vm::onRedeem,
+ onDismiss = { resultBackNavigator.navigateBack(result = it) }
+ )
+}
+
@Composable
fun RedeemVoucherDialog(
uiState: VoucherDialogUiState,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt
new file mode 100644
index 0000000000..859f28fea3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.NegativeButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.component.HtmlText
+import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.model.Device
+
+@Preview
+@Composable
+private fun PreviewRemoveDeviceConfirmationDialog() {
+ AppTheme {
+ RemoveDeviceConfirmationDialog(
+ EmptyResultBackNavigator(),
+ device = Device("test", "test", byteArrayOf(), "test")
+ )
+ }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<String>, device: Device) {
+ AlertDialog(
+ onDismissRequest = { navigator.navigateBack() },
+ title = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(top = 0.dp).fillMaxWidth()
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_alert),
+ contentDescription = "Remove",
+ modifier = Modifier.width(50.dp).height(50.dp)
+ )
+ }
+ },
+ text = {
+ val htmlFormattedDialogText =
+ textResource(
+ id = R.string.max_devices_confirm_removal_description,
+ device.displayName()
+ )
+
+ HtmlText(htmlFormattedString = htmlFormattedDialogText, textSize = 16.sp.value)
+ },
+ dismissButton = {
+ NegativeButton(
+ onClick = { navigator.navigateBack(result = device.id) },
+ text = stringResource(id = R.string.confirm_removal)
+ )
+ },
+ confirmButton = {
+ PrimaryButton(
+ modifier = Modifier.focusRequester(FocusRequester()),
+ onClick = { navigator.navigateBack() },
+ text = stringResource(id = R.string.back)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt
index 1e2da9f951..f053cd74f6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt
@@ -12,6 +12,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.NegativeButton
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
@@ -21,18 +25,14 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens
@Preview
@Composable
private fun PreviewReportProblemNoEmailDialog() {
- AppTheme {
- ReportProblemNoEmailDialog(
- onDismiss = {},
- onConfirm = {},
- )
- }
+ AppTheme { ReportProblemNoEmailDialog(EmptyResultBackNavigator()) }
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
+fun ReportProblemNoEmailDialog(resultBackNavigator: ResultBackNavigator<Boolean>) {
AlertDialog(
- onDismissRequest = { onDismiss() },
+ onDismissRequest = resultBackNavigator::navigateBack,
icon = {
Icon(
painter = painterResource(id = R.drawable.icon_alert),
@@ -52,14 +52,14 @@ fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
dismissButton = {
NegativeButton(
modifier = Modifier.fillMaxWidth(),
- onClick = onConfirm,
+ onClick = { resultBackNavigator.navigateBack(result = true) },
text = stringResource(id = R.string.send_anyway)
)
},
confirmButton = {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
- onClick = { onDismiss() },
+ onClick = resultBackNavigator::navigateBack,
text = stringResource(id = R.string.back)
)
},
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt
index f814127990..1c5c4ccef6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt
@@ -3,18 +3,24 @@ package net.mullvad.mullvadvpn.compose.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
@Preview
@Composable
private fun PreviewUdpOverTcpPortInfoDialog() {
- UdpOverTcpPortInfoDialog(onDismiss = {})
+ AppTheme { UdpOverTcpPortInfoDialog(EmptyDestinationsNavigator) }
}
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun UdpOverTcpPortInfoDialog(onDismiss: () -> Unit) {
+fun UdpOverTcpPortInfoDialog(navigator: DestinationsNavigator) {
InfoDialog(
message = stringResource(id = R.string.udp_over_tcp_port_info),
- onDismiss = onDismiss
+ onDismiss = navigator::navigateUp
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt
new file mode 100644
index 0000000000..9b2f495f4d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt
@@ -0,0 +1,139 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import android.os.Parcelable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import kotlinx.parcelize.Parcelize
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.NegativeButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.model.PortRange
+import net.mullvad.mullvadvpn.util.asString
+import net.mullvad.mullvadvpn.util.isPortInValidRanges
+
+@Preview
+@Composable
+private fun PreviewWireguardCustomPortDialog() {
+ AppTheme {
+ WireguardCustomPortDialog(
+ WireguardCustomPortNavArgs(
+ customPort = null,
+ allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)),
+ ),
+ EmptyResultBackNavigator()
+ )
+ }
+}
+
+@Parcelize
+data class WireguardCustomPortNavArgs(
+ val customPort: Int?,
+ val allowedPortRanges: List<PortRange>,
+) : Parcelable
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun WireguardCustomPortDialog(
+ navArg: WireguardCustomPortNavArgs,
+ backNavigator: ResultBackNavigator<Int?>,
+) {
+ WireguardCustomPortDialog(
+ initialPort = navArg.customPort,
+ allowedPortRanges = navArg.allowedPortRanges,
+ onSave = { port -> backNavigator.navigateBack(port) },
+ onDismiss = backNavigator::navigateBack
+ )
+}
+
+@Composable
+fun WireguardCustomPortDialog(
+ initialPort: Int?,
+ allowedPortRanges: List<PortRange>,
+ onSave: (Int?) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val port = remember { mutableStateOf(initialPort?.toString() ?: "") }
+
+ AlertDialog(
+ title = {
+ Text(
+ text = stringResource(id = R.string.custom_port_dialog_title),
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ confirmButton = {
+ Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) {
+ PrimaryButton(
+ text = stringResource(id = R.string.custom_port_dialog_submit),
+ onClick = { onSave(port.value.toInt()) },
+ isEnabled =
+ port.value.isNotEmpty() &&
+ allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0)
+ )
+ if (initialPort != null) {
+ NegativeButton(
+ text = stringResource(R.string.custom_port_dialog_remove),
+ onClick = { onSave(null) }
+ )
+ }
+ PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss)
+ }
+ },
+ text = {
+ Column {
+ CustomPortTextField(
+ value = port.value,
+ onSubmit = { input ->
+ if (
+ input.isNotEmpty() &&
+ allowedPortRanges.isPortInValidRanges(input.toIntOrNull() ?: 0)
+ ) {
+ onSave(input.toIntOrNull())
+ }
+ },
+ onValueChanged = { input -> port.value = input },
+ isValidValue =
+ port.value.isNotEmpty() &&
+ allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0),
+ maxCharLength = 5,
+ modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(Dimens.smallPadding))
+ Text(
+ text =
+ stringResource(
+ id = R.string.custom_port_dialog_valid_ranges,
+ allowedPortRanges.asString()
+ ),
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaDescription),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ onDismissRequest = onDismiss
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt
index 58ddb00e20..a3329b1248 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt
@@ -1,24 +1,45 @@
package net.mullvad.mullvadvpn.compose.dialog
+import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.util.asString
@Preview
@Composable
private fun PreviewWireguardPortInfoDialog() {
- WireguardPortInfoDialog(portRanges = listOf(PortRange(1, 2)), onDismiss = {})
+ AppTheme {
+ WireguardPortInfoDialog(
+ EmptyDestinationsNavigator,
+ argument = WireguardPortInfoDialogArgument(listOf(PortRange(1, 2)))
+ )
+ }
}
+@Parcelize data class WireguardPortInfoDialogArgument(val portRanges: List<PortRange>) : Parcelable
+
+@Destination(style = DestinationStyle.Dialog::class)
@Composable
-fun WireguardPortInfoDialog(portRanges: List<PortRange>, onDismiss: () -> Unit) {
+fun WireguardPortInfoDialog(
+ navigator: DestinationsNavigator,
+ argument: WireguardPortInfoDialogArgument
+) {
InfoDialog(
message = stringResource(id = R.string.wireguard_port_info_description),
additionalInfo =
- stringResource(id = R.string.wireguard_port_info_port_range, portRanges.asString()),
- onDismiss = onDismiss
+ stringResource(
+ id = R.string.wireguard_port_info_port_range,
+ argument.portRanges.asString()
+ ),
+ onDismiss = navigator::navigateUp
)
}
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 7e94b7455e..88c305b8c0 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
@@ -5,17 +5,28 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+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.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.util.getActivity
+import net.mullvad.mullvadvpn.viewmodel.PaymentUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -108,11 +119,38 @@ private fun PreviewPaymentDialogPaymentAvailabilityError() {
}
}
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boolean>) {
+ val vm = koinViewModel<PaymentViewModel>()
+ val uiState = vm.uiState.collectAsState().value
+
+ LaunchedEffect(Unit) {
+ vm.uiSideEffect.collect {
+ when (it) {
+ is PaymentUiSideEffect.PaymentCancelled ->
+ resultBackNavigator.navigateBack(result = false)
+ }
+ }
+ }
+
+ val context = LocalContext.current
+ LaunchedEffect(Unit) { vm.startBillingPayment(productId) { context.getActivity()!! } }
+
+ if (uiState.paymentDialogData != null) {
+ PaymentDialog(
+ paymentDialogData = uiState.paymentDialogData,
+ retryPurchase = { vm.startBillingPayment(it) { context.getActivity()!! } },
+ onCloseDialog = { resultBackNavigator.navigateBack(result = it) }
+ )
+ }
+}
+
@Composable
fun PaymentDialog(
paymentDialogData: PaymentDialogData,
- retryPurchase: (ProductId) -> Unit,
- onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit
+ retryPurchase: (ProductId) -> Unit = {},
+ onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit = {}
) {
val clickResolver: (action: PaymentDialogAction) -> Unit = {
when (it) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt
index 112afeebf5..7d49a133f3 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt
@@ -7,6 +7,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -18,6 +21,12 @@ private fun PreviewVerificationPendingDialog() {
AppTheme { VerificationPendingDialog(onClose = {}) }
}
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun VerificationPendingDialog(navigator: DestinationsNavigator) {
+ VerificationPendingDialog(onClose = navigator::navigateUp)
+}
+
@Composable
fun VerificationPendingDialog(onClose: () -> Unit) {
AlertDialog(
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 fecd23406a..38404ec96b 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
@@ -1,6 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
+import android.os.Build
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -13,25 +13,32 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.ExternalButton
import net.mullvad.mullvadvpn.compose.button.NegativeButton
import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton
@@ -41,11 +48,13 @@ import net.mullvad.mullvadvpn.compose.component.MissingPolicy
import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton
import net.mullvad.mullvadvpn.compose.component.PlayPayment
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
-import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
-import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
-import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
+import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.LoginDestination
+import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination
+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.SecureScreenWhileInView
import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
@@ -58,6 +67,7 @@ import net.mullvad.mullvadvpn.util.toExpiryDateString
import net.mullvad.mullvadvpn.viewmodel.AccountUiState
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import org.joda.time.DateTime
+import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@@ -65,12 +75,12 @@ import org.joda.time.DateTime
private fun PreviewAccountScreen() {
AppTheme {
AccountScreen(
- showSitePayment = true,
uiState =
AccountUiState(
deviceName = "Test Name",
accountNumber = "1234123412341234",
accountExpiry = null,
+ showSitePayment = true,
billingPaymentState =
PaymentState.PaymentAvailable(
listOf(
@@ -88,70 +98,94 @@ private fun PreviewAccountScreen() {
)
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow()
)
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Destination(style = SlideInFromBottomTransition::class)
+@Composable
+fun Account(
+ navigator: DestinationsNavigator,
+ playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>
+) {
+ val vm = koinViewModel<AccountViewModel>()
+ val state by vm.uiState.collectAsState()
+
+ playPaymentResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value)
+ }
+ }
+
+ AccountScreen(
+ uiState = state,
+ uiSideEffect = vm.uiSideEffect,
+ onRedeemVoucherClick = {
+ navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true }
+ },
+ onManageAccountClick = vm::onManageAccountClick,
+ onLogoutClick = vm::onLogoutClick,
+ navigateToLogin = {
+ navigator.navigate(LoginDestination(null)) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ },
+ onCopyAccountNumber = vm::onCopyAccountNumber,
+ onBackClick = navigator::navigateUp,
+ navigateToDeviceInfo = {
+ navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true }
+ },
+ onPurchaseBillingProductClick = { productId ->
+ navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true }
+ },
+ navigateToVerificationPendingDialog = {
+ navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true }
+ }
+ )
+}
+
@ExperimentalMaterial3Api
@Composable
fun AccountScreen(
- showSitePayment: Boolean,
uiState: AccountUiState,
- uiSideEffect: SharedFlow<AccountViewModel.UiSideEffect>,
- enterTransitionEndAction: SharedFlow<Unit>,
+ uiSideEffect: Flow<AccountViewModel.UiSideEffect>,
+ onCopyAccountNumber: (String) -> Unit = {},
onRedeemVoucherClick: () -> Unit = {},
onManageAccountClick: () -> Unit = {},
onLogoutClick: () -> Unit = {},
- onPurchaseBillingProductClick:
- (productId: ProductId, activityProvider: () -> Activity) -> Unit =
- { _, _ ->
- },
- onClosePurchaseResultDialog: (success: Boolean) -> Unit = {},
+ onPurchaseBillingProductClick: (productId: ProductId) -> Unit = { _ -> },
+ navigateToLogin: () -> Unit = {},
+ navigateToDeviceInfo: () -> Unit = {},
+ navigateToVerificationPendingDialog: () -> Unit = {},
onBackClick: () -> Unit = {}
) {
// This will enable SECURE_FLAG while this screen is visible to preview screenshot
SecureScreenWhileInView()
val context = LocalContext.current
- val backgroundColor = MaterialTheme.colorScheme.background
- val systemUiController = rememberSystemUiController()
- var showDeviceNameInfoDialog by remember { mutableStateOf(false) }
- var showVerificationPendingDialog by remember { mutableStateOf(false) }
-
- LaunchedEffect(Unit) {
- systemUiController.setNavigationBarColor(backgroundColor)
- enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) }
- }
- val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
- LaunchedEffect(Unit) {
- uiSideEffect.collect { viewAction ->
- if (viewAction is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) {
- openAccountPage(viewAction.token)
- }
- }
- }
-
- if (showDeviceNameInfoDialog) {
- DeviceNameInfoDialog { showDeviceNameInfoDialog = false }
- }
-
- if (showVerificationPendingDialog) {
- VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
- }
-
- uiState.paymentDialogData?.let {
- PaymentDialog(
- paymentDialogData = uiState.paymentDialogData,
- retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
- onCloseDialog = onClosePurchaseResultDialog
- )
- }
-
+ val clipboardManager = LocalClipboardManager.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ val copyTextString = stringResource(id = R.string.copied_mullvad_account_number)
LaunchedEffect(Unit) {
uiSideEffect.collect { uiSideEffect ->
- if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) {
- context.openAccountPageInBrowser(uiSideEffect.token)
+ when (uiSideEffect) {
+ AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin()
+ is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser ->
+ context.openAccountPageInBrowser(uiSideEffect.token)
+ is AccountViewModel.UiSideEffect.CopyAccountNumber ->
+ launch {
+ clipboardManager.setText(AnnotatedString(uiSideEffect.accountNumber))
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ snackbarHostState.currentSnackbarData?.dismiss()
+ snackbarHostState.showSnackbar(message = copyTextString)
+ }
+ }
}
}
}
@@ -165,9 +199,9 @@ fun AccountScreen(
verticalArrangement = Arrangement.spacedBy(Dimens.accountRowSpacing),
modifier = modifier.animateContentSize().padding(horizontal = Dimens.sideMargin)
) {
- DeviceNameRow(deviceName = uiState.deviceName ?: "") { showDeviceNameInfoDialog = true }
+ DeviceNameRow(deviceName = uiState.deviceName ?: "", onInfoClick = navigateToDeviceInfo)
- AccountNumberRow(accountNumber = uiState.accountNumber ?: "")
+ AccountNumberRow(accountNumber = uiState.accountNumber ?: "", onCopyAccountNumber)
PaidUntilRow(accountExpiry = uiState.accountExpiry)
@@ -178,14 +212,14 @@ fun AccountScreen(
PlayPayment(
billingPaymentState = uiState.billingPaymentState,
onPurchaseBillingProductClick = { productId ->
- onPurchaseBillingProductClick(productId) { context as Activity }
+ onPurchaseBillingProductClick(productId)
},
- onInfoClick = { showVerificationPendingDialog = true },
+ onInfoClick = navigateToVerificationPendingDialog,
modifier = Modifier.padding(bottom = Dimens.buttonSpacing)
)
}
- if (showSitePayment) {
+ if (uiState.showSitePayment) {
ExternalButton(
text = stringResource(id = R.string.manage_account),
onClick = onManageAccountClick,
@@ -230,7 +264,7 @@ private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) {
}
@Composable
-private fun AccountNumberRow(accountNumber: String) {
+private fun AccountNumberRow(accountNumber: String, onCopyAccountNumber: (String) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
style = MaterialTheme.typography.labelMedium,
@@ -238,6 +272,7 @@ private fun AccountNumberRow(accountNumber: String) {
)
CopyableObfuscationView(
content = accountNumber,
+ onCopyClicked = { onCopyAccountNumber(accountNumber) },
modifier = Modifier.heightIn(min = Dimens.accountRowMinHeight).fillMaxWidth()
)
}
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 7528b46e42..cbf1f53c3d 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
@@ -1,5 +1,7 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.content.Intent
+import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
@@ -24,11 +27,11 @@ 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 com.google.accompanist.systemuicontroller.rememberSystemUiController
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.ConnectionButton
import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton
import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText
@@ -37,6 +40,10 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner
+import net.mullvad.mullvadvpn.compose.destinations.AccountDestination
+import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination
+import net.mullvad.mullvadvpn.compose.destinations.SelectLocationDestination
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG
@@ -44,14 +51,17 @@ import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG
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.lib.common.util.openAccountPageInBrowser
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+import org.koin.androidx.compose.koinViewModel
private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000
@@ -62,16 +72,64 @@ private fun PreviewConnectScreen() {
AppTheme {
ConnectScreen(
uiState = state,
- uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
}
+@Destination(style = HomeTransition::class)
+@Composable
+fun Connect(navigator: DestinationsNavigator) {
+ val connectViewModel: ConnectViewModel = koinViewModel()
+
+ val state = connectViewModel.uiState.collectAsState().value
+
+ 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 }
+ }
+ }
+ }
+ }
+ }
+ ConnectScreen(
+ uiState = state,
+ onDisconnectClick = connectViewModel::onDisconnectClick,
+ onReconnectClick = connectViewModel::onReconnectClick,
+ onConnectClick = connectViewModel::onConnectClick,
+ onCancelClick = connectViewModel::onCancelClick,
+ onSwitchLocationClick = {
+ navigator.navigate(SelectLocationDestination) { launchSingleTop = true }
+ },
+ onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion,
+ onUpdateVersionClick = {
+ val intent =
+ Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse(
+ context.getString(R.string.download_url).appendHideNavOnPlayBuild()
+ )
+ )
+ .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
+ context.startActivity(intent)
+ },
+ onManageAccountClick = connectViewModel::onManageAccountClick,
+ onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } },
+ onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } },
+ onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification,
+ )
+}
+
@Composable
fun ConnectScreen(
uiState: ConnectUiState,
- uiSideEffect: SharedFlow<ConnectViewModel.UiSideEffect>,
- drawNavigationBar: Boolean = false,
onDisconnectClick: () -> Unit = {},
onReconnectClick: () -> Unit = {},
onConnectClick: () -> Unit = {},
@@ -80,33 +138,10 @@ fun ConnectScreen(
onToggleTunnelInfo: () -> Unit = {},
onUpdateVersionClick: () -> Unit = {},
onManageAccountClick: () -> Unit = {},
- onOpenOutOfTimeScreen: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onDismissNewDeviceClick: () -> Unit = {}
) {
- val context = LocalContext.current
-
- val systemUiController = rememberSystemUiController()
- val navigationBarColor = MaterialTheme.colorScheme.primary
- val setSystemBarColor = { systemUiController.setNavigationBarColor(navigationBarColor) }
- LaunchedEffect(drawNavigationBar) {
- if (drawNavigationBar) {
- setSystemBarColor()
- }
- }
- LaunchedEffect(key1 = Unit) {
- uiSideEffect.collect { uiSideEffect ->
- when (uiSideEffect) {
- is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> {
- context.openAccountPageInBrowser(uiSideEffect.token)
- }
- is ConnectViewModel.UiSideEffect.OpenOutOfTimeView -> {
- onOpenOutOfTimeScreen()
- }
- }
- }
- }
val scrollState = rememberScrollState()
var lastConnectionActionTimestamp by remember { mutableLongStateOf(0L) }
@@ -126,13 +161,6 @@ fun ConnectScreen(
} else {
MaterialTheme.colorScheme.error
},
- statusBarColor =
- if (uiState.tunnelUiState.isSecured()) {
- MaterialTheme.colorScheme.inversePrimary
- } else {
- MaterialTheme.colorScheme.error
- },
- navigationBarColor = null,
iconTintColor =
if (uiState.tunnelUiState.isSecured()) {
MaterialTheme.colorScheme.onPrimary
@@ -149,8 +177,8 @@ fun ConnectScreen(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.Start,
modifier =
- Modifier.padding(it)
- .background(color = MaterialTheme.colorScheme.primary)
+ Modifier.background(color = MaterialTheme.colorScheme.primary)
+ .padding(it)
.fillMaxHeight()
.drawVerticalScrollbar(
scrollState,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
index 1617c1fb7a..96f2894a23 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
@@ -17,12 +17,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
@@ -30,7 +37,9 @@ import net.mullvad.mullvadvpn.compose.cell.BaseCell
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
-import net.mullvad.mullvadvpn.compose.dialog.DeviceRemovalDialog
+import net.mullvad.mullvadvpn.compose.destinations.LoginDestination
+import net.mullvad.mullvadvpn.compose.destinations.RemoveDeviceConfirmationDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
import net.mullvad.mullvadvpn.compose.state.DeviceListItemUiState
import net.mullvad.mullvadvpn.compose.state.DeviceListUiState
import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime
@@ -42,6 +51,8 @@ import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText
import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText
import net.mullvad.mullvadvpn.model.Device
import net.mullvad.mullvadvpn.util.formatDate
+import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
+import org.koin.androidx.compose.koinViewModel
@Composable
@Preview
@@ -63,35 +74,62 @@ private fun PreviewDeviceListScreen() {
isLoading = false
)
),
- isLoading = true,
- stagedDevice = null
+ isLoading = true
)
)
}
}
+@Destination
+@Composable
+fun DeviceList(
+ navigator: DestinationsNavigator,
+ accountToken: String,
+ confirmRemoveResultRecipient: ResultRecipient<RemoveDeviceConfirmationDialogDestination, String>
+) {
+ val viewModel = koinViewModel<DeviceListViewModel>()
+ val state by viewModel.uiState.collectAsState()
+
+ confirmRemoveResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> {
+ viewModel.removeDevice(accountToken = accountToken, deviceIdToRemove = it.value)
+ }
+ }
+ }
+
+ DeviceListScreen(
+ state = state,
+ onBackClick = navigator::navigateUp,
+ onContinueWithLogin = {
+ navigator.navigate(LoginDestination(accountToken)) {
+ launchSingleTop = true
+ popUpTo(LoginDestination) { inclusive = true }
+ }
+ },
+ onSettingsClicked = { navigator.navigate(SettingsDestination) { launchSingleTop = true } },
+ navigateToRemoveDeviceConfirmationDialog = {
+ navigator.navigate(RemoveDeviceConfirmationDialogDestination(it)) {
+ launchSingleTop = true
+ }
+ }
+ )
+}
+
@Composable
fun DeviceListScreen(
state: DeviceListUiState,
onBackClick: () -> Unit = {},
onContinueWithLogin: () -> Unit = {},
onSettingsClicked: () -> Unit = {},
- onDeviceRemovalClicked: (deviceId: String) -> Unit = {},
- onDismissDeviceRemovalDialog: () -> Unit = {},
- onConfirmDeviceRemovalDialog: () -> Unit = {}
+ navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit = {}
) {
- if (state.stagedDevice != null) {
- DeviceRemovalDialog(
- onDismiss = onDismissDeviceRemovalDialog,
- onConfirmDeviceRemovalDialog,
- device = state.stagedDevice
- )
- }
ScaffoldWithTopBar(
topBarColor = MaterialTheme.colorScheme.primary,
- statusBarColor = MaterialTheme.colorScheme.primary,
- navigationBarColor = MaterialTheme.colorScheme.background,
iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
onSettingsClicked = onSettingsClicked,
onAccountClicked = null,
@@ -115,7 +153,7 @@ fun DeviceListScreen(
DeviceListItem(
deviceUiState = deviceUiState,
) {
- onDeviceRemovalClicked(deviceUiState.device.id)
+ navigateToRemoveDeviceConfirmationDialog(deviceUiState.device)
}
if (state.deviceUiItems.lastIndex != index) {
Divider()
@@ -244,7 +282,6 @@ private fun DeviceListButtonPanel(
onContinueWithLogin: () -> Unit,
onBackClick: () -> Unit
) {
-
Column(
modifier =
Modifier.padding(
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 5ec6b9a64b..11e929c905 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
@@ -3,14 +3,15 @@ package net.mullvad.mullvadvpn.compose.screen
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
@@ -21,12 +22,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.DeviceRevokedLoginButton
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.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -34,6 +43,24 @@ private fun PreviewDeviceRevokedScreen() {
AppTheme { DeviceRevokedScreen(state = DeviceRevokedUiState.SECURED) }
}
+@Destination
+@Composable
+fun DeviceRevoked(navigator: DestinationsNavigator) {
+ val viewModel = koinViewModel<DeviceRevokedViewModel>()
+
+ val state by viewModel.uiState.collectAsState()
+ DeviceRevokedScreen(
+ state = state,
+ onSettingsClicked = { navigator.navigate(SettingsDestination) { launchSingleTop = true } },
+ onGoToLoginClicked = {
+ navigator.navigate(LoginDestination(null)) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ )
+}
+
@Composable
fun DeviceRevokedScreen(
state: DeviceRevokedUiState,
@@ -49,15 +76,12 @@ fun DeviceRevokedScreen(
ScaffoldWithTopBar(
topBarColor = topColor,
- statusBarColor = topColor,
- navigationBarColor = MaterialTheme.colorScheme.background,
onSettingsClicked = onSettingsClicked,
onAccountClicked = null
) {
ConstraintLayout(
modifier =
- Modifier.fillMaxHeight()
- .fillMaxWidth()
+ Modifier.fillMaxSize()
.padding(it)
.background(color = MaterialTheme.colorScheme.background)
) {
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 844360c16c..b9db62d620 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -8,37 +7,45 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.ApplyButton
import net.mullvad.mullvadvpn.compose.cell.CheckboxCell
import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell
import net.mullvad.mullvadvpn.compose.cell.SelectableCell
import net.mullvad.mullvadvpn.compose.state.RelayFilterState
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.model.Ownership
import net.mullvad.mullvadvpn.relaylist.Provider
+import net.mullvad.mullvadvpn.viewmodel.FilterScreenSideEffect
+import net.mullvad.mullvadvpn.viewmodel.FilterViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -53,49 +60,63 @@ private fun PreviewFilterScreen() {
FilterScreen(
uiState = state,
onSelectedOwnership = {},
- onSelectedProviders = { _, _ -> },
+ onSelectedProvider = { _, _ -> },
onAllProviderCheckChange = {},
- uiCloseAction = MutableSharedFlow()
)
}
}
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun FilterScreen(navigator: DestinationsNavigator) {
+ val viewModel = koinViewModel<FilterViewModel>()
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.uiSideEffect.collect {
+ when (it) {
+ FilterScreenSideEffect.CloseScreen -> navigator.navigateUp()
+ }
+ }
+ }
+ FilterScreen(
+ uiState = uiState,
+ onBackClick = navigator::navigateUp,
+ onApplyClick = viewModel::onApplyButtonClicked,
+ onSelectedOwnership = viewModel::setSelectedOwnership,
+ onAllProviderCheckChange = viewModel::setAllProviders,
+ onSelectedProvider = viewModel::setSelectedProvider
+ )
+}
+
@Composable
fun FilterScreen(
uiState: RelayFilterState,
onBackClick: () -> Unit = {},
- uiCloseAction: SharedFlow<Unit>,
onApplyClick: () -> Unit = {},
onSelectedOwnership: (ownership: Ownership?) -> Unit = {},
onAllProviderCheckChange: (isChecked: Boolean) -> Unit = {},
- onSelectedProviders: (checked: Boolean, provider: Provider) -> Unit
+ onSelectedProvider: (checked: Boolean, provider: Provider) -> Unit
) {
var providerExpanded by rememberSaveable { mutableStateOf(false) }
var ownershipExpanded by rememberSaveable { mutableStateOf(false) }
val backgroundColor = MaterialTheme.colorScheme.background
- LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } }
Scaffold(
+ modifier = Modifier.background(backgroundColor).systemBarsPadding().fillMaxSize(),
topBar = {
- Row(
- Modifier.padding(
- horizontal = Dimens.selectFilterTitlePadding,
- vertical = Dimens.selectFilterTitlePadding
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_back),
+ contentDescription = null,
+ tint = Color.Unspecified,
)
- .fillMaxWidth(),
- ) {
- Image(
- painter = painterResource(id = R.drawable.icon_back),
- contentDescription = null,
- modifier = Modifier.size(Dimens.titleIconSize).clickable(onClick = onBackClick)
- )
+ }
Text(
text = stringResource(R.string.filter),
- modifier =
- Modifier.align(Alignment.CenterVertically)
- .weight(weight = 1f)
- .padding(end = Dimens.titleIconSize),
+ modifier = Modifier.weight(1f).padding(end = Dimens.titleIconSize),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onPrimary
@@ -124,9 +145,7 @@ fun FilterScreen(
}
},
) { contentPadding ->
- LazyColumn(
- modifier = Modifier.padding(contentPadding).background(backgroundColor).fillMaxSize()
- ) {
+ LazyColumn(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
item {
Divider()
ExpandableComposeCell(
@@ -178,7 +197,7 @@ fun FilterScreen(
CheckboxCell(
providerName = provider.name,
checked = provider in uiState.selectedProviders,
- onCheckedChange = { checked -> onSelectedProviders(checked, provider) }
+ onCheckedChange = { checked -> onSelectedProvider(checked, provider) }
)
}
}
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 113ef4b020..4dac203fa8 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
@@ -26,6 +26,8 @@ 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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -43,18 +45,25 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination
+import net.mullvad.mullvadvpn.compose.destinations.DeviceListDestination
+import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
+import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination
import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState
import net.mullvad.mullvadvpn.compose.state.LoginState.Idle
@@ -64,10 +73,14 @@ import net.mullvad.mullvadvpn.compose.state.LoginUiState
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.accountTokenVisualTransformation
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
+import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -101,9 +114,63 @@ private fun PreviewLoginSuccess() {
AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) }
}
-@OptIn(ExperimentalComposeUiApi::class)
+@Destination(style = LoginTransition::class)
+@Composable
+fun Login(
+ navigator: DestinationsNavigator,
+ accountToken: String? = null,
+ vm: LoginViewModel = koinViewModel()
+) {
+ val state by vm.uiState.collectAsState()
+
+ // Login with argument, e.g when user comes from Too Many Devices screen
+ LaunchedEffect(accountToken) {
+ if (accountToken != null) {
+ vm.onAccountNumberChange(accountToken)
+ vm.login(accountToken)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ vm.uiSideEffect.collect {
+ 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.TooManyDevices -> {
+ navigator.navigate(DeviceListDestination(it.accountToken.value)) {
+ launchSingleTop = true
+ }
+ }
+ LoginUiSideEffect.NavigateToOutOfTime ->
+ navigator.navigate(OutOfTimeDestination) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ }
+ }
+ LoginScreen(
+ state,
+ vm::login,
+ vm::createAccount,
+ vm::clearAccountHistory,
+ vm::onAccountNumberChange,
+ { navigator.navigate(SettingsDestination) }
+ )
+}
+
@Composable
-fun LoginScreen(
+private fun LoginScreen(
uiState: LoginUiState,
onLoginClick: (String) -> Unit = {},
onCreateAccountClick: () -> Unit = {},
@@ -112,13 +179,11 @@ fun LoginScreen(
onSettingsClick: () -> Unit = {},
) {
ScaffoldWithTopBar(
- modifier = Modifier.semantics { testTagsAsResourceId = true },
topBarColor = MaterialTheme.colorScheme.primary,
- statusBarColor = MaterialTheme.colorScheme.primary,
- navigationBarColor = MaterialTheme.colorScheme.background,
iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
onSettingsClicked = onSettingsClick,
- onAccountClicked = null
+ enabled = uiState.loginState is Idle,
+ onAccountClicked = null,
) {
val scrollState = rememberScrollState()
Column(
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
new file mode 100644
index 0000000000..8ef535a58b
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
@@ -0,0 +1,77 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.navigation.NavHostController
+import com.ramcosta.composedestinations.DestinationsNavHost
+import com.ramcosta.composedestinations.navigation.navigate
+import com.ramcosta.composedestinations.navigation.popBackStack
+import com.ramcosta.composedestinations.rememberNavHostEngine
+import com.ramcosta.composedestinations.utils.destination
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import net.mullvad.mullvadvpn.compose.NavGraphs
+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.viewmodel.ChangelogViewModel
+import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent
+import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel
+import org.koin.androidx.compose.koinViewModel
+
+private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination)
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun MullvadApp() {
+ val engine = rememberNavHostEngine()
+ val navController: NavHostController = engine.rememberNavController()
+
+ val serviceVm = koinViewModel<NoDaemonViewModel>()
+
+ DisposableEffect(Unit) {
+ navController.addOnDestinationChangedListener(serviceVm)
+ onDispose { navController.removeOnDestinationChangedListener(serviceVm) }
+ }
+
+ DestinationsNavHost(
+ modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(),
+ engine = engine,
+ navController = navController,
+ navGraph = NavGraphs.root
+ )
+
+ // 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)
+ }
+ }
+ }
+
+ // Globally show the changelog
+ val changeLogsViewModel = koinViewModel<ChangelogViewModel>()
+ LaunchedEffect(Unit) {
+ changeLogsViewModel.uiSideEffect.collect {
+
+ // Wait until we are in an acceptable destination
+ navController.currentBackStackEntryFlow
+ .map { it.destination() }
+ .first { it in changeLogDestinations }
+
+ navController.navigate(ChangelogDestination(it).route)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt
new file mode 100644
index 0000000000..af47b37fc2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt
@@ -0,0 +1,104 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.app.ActivityCompat.finishAffinity
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
+import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.util.getActivity
+
+@Preview
+@Composable
+private fun PreviewNoDaemonScreen() {
+ AppTheme { NoDaemonScreen({}) }
+}
+
+// Set this as the start destination of the default nav graph
+@Destination(style = DefaultTransition::class)
+@Composable
+fun NoDaemonScreen(navigator: DestinationsNavigator) {
+ NoDaemonScreen { navigator.navigate(SettingsDestination) }
+}
+
+@Composable
+fun NoDaemonScreen(onNavigateToSettings: () -> Unit) {
+
+ val backgroundColor = MaterialTheme.colorScheme.primary
+
+ val context = LocalContext.current
+ BackHandler { finishAffinity(context.getActivity()!!) }
+
+ ScaffoldWithTopBar(
+ topBarColor = backgroundColor,
+ onSettingsClicked = onNavigateToSettings,
+ onAccountClicked = null,
+ isIconAndLogoVisible = false,
+ content = {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier.background(backgroundColor)
+ .padding(it)
+ .padding(bottom = it.calculateTopPadding())
+ .fillMaxSize()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.launch_logo),
+ contentDescription = "",
+ modifier = Modifier.size(Dimens.splashLogoSize)
+ )
+ Image(
+ painter = painterResource(id = R.drawable.logo_text),
+ contentDescription = "",
+ alpha = 0.6f,
+ modifier =
+ Modifier.padding(top = Dimens.mediumPadding)
+ .height(Dimens.splashLogoTextHeight)
+ )
+ Text(
+ text = stringResource(id = R.string.connecting_to_daemon),
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ MaterialTheme.colorScheme.onPrimary
+ .copy(alpha = AlphaDescription)
+ .compositeOver(backgroundColor),
+ modifier =
+ Modifier.padding(top = Dimens.mediumPadding)
+ .padding(horizontal = Dimens.sideMargin),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ )
+}
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 b7b4744bb2..d9071be7d8 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
@@ -15,31 +14,35 @@ 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.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
+import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.NegativeButton
import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton
import net.mullvad.mullvadvpn.compose.button.SitePaymentButton
import net.mullvad.mullvadvpn.compose.component.PlayPayment
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
-import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
+import net.mullvad.mullvadvpn.compose.destinations.AccountDestination
+import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination
+import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination
+import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
+import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination
import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+import net.mullvad.mullvadvpn.compose.transitions.HomeTransition
import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -50,15 +53,19 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import net.mullvad.talpid.tunnel.ActionAfterDisconnect
import net.mullvad.talpid.tunnel.ErrorState
import net.mullvad.talpid.tunnel.ErrorStateCause
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
private fun PreviewOutOfTimeScreenDisconnected() {
AppTheme {
OutOfTimeScreen(
- showSitePayment = true,
- uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected, "Heroic Frog"),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ uiState =
+ OutOfTimeUiState(
+ tunnelState = TunnelState.Disconnected,
+ "Heroic Frog",
+ showSitePayment = true
+ ),
)
}
}
@@ -68,10 +75,12 @@ private fun PreviewOutOfTimeScreenDisconnected() {
private fun PreviewOutOfTimeScreenConnecting() {
AppTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
- OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null), "Strong Rabbit"),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ OutOfTimeUiState(
+ tunnelState = TunnelState.Connecting(null, null),
+ "Strong Rabbit",
+ showSitePayment = true
+ ),
)
}
}
@@ -81,59 +90,92 @@ private fun PreviewOutOfTimeScreenConnecting() {
private fun PreviewOutOfTimeScreenError() {
AppTheme {
OutOfTimeScreen(
- showSitePayment = true,
uiState =
OutOfTimeUiState(
tunnelState =
TunnelState.Error(
ErrorState(cause = ErrorStateCause.IsOffline, isBlocking = true)
),
- deviceName = "Stable Horse"
+ deviceName = "Stable Horse",
+ showSitePayment = true
),
- uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
)
}
}
+@Destination(style = HomeTransition::class)
@Composable
-fun OutOfTimeScreen(
- showSitePayment: Boolean,
- uiState: OutOfTimeUiState,
- uiSideEffect: SharedFlow<OutOfTimeViewModel.UiSideEffect>,
- onDisconnectClick: () -> Unit = {},
- onSitePaymentClick: () -> Unit = {},
- onRedeemVoucherClick: () -> Unit = {},
- openConnectScreen: () -> Unit = {},
- onSettingsClick: () -> Unit = {},
- onAccountClick: () -> Unit = {},
- onPurchaseBillingProductClick: (ProductId, activityProvider: () -> Activity) -> Unit = { _, _ ->
- },
- onClosePurchaseResultDialog: (success: Boolean) -> Unit = {}
+fun OutOfTime(
+ navigator: DestinationsNavigator,
+ redeemVoucherResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>,
+ playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>
) {
- val context = LocalContext.current
+ val vm = koinViewModel<OutOfTimeViewModel>()
+ val state = vm.uiState.collectAsState().value
+ redeemVoucherResultRecipient.onNavResult {
+ // If we successfully redeemed a voucher, navigate to Connect screen
+ if (it is NavResult.Value && it.value) {
+ navigator.navigate(ConnectDestination) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ }
+
+ playPaymentResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value)
+ }
+ }
+
val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
- LaunchedEffect(key1 = Unit) {
- uiSideEffect.collect { uiSideEffect ->
+ LaunchedEffect(Unit) {
+ vm.uiSideEffect.collect { uiSideEffect ->
when (uiSideEffect) {
is OutOfTimeViewModel.UiSideEffect.OpenAccountView ->
openAccountPage(uiSideEffect.token)
- OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> openConnectScreen()
+ OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> {
+ navigator.navigate(ConnectDestination) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
}
}
}
- var showVerificationPendingDialog by remember { mutableStateOf(false) }
- if (showVerificationPendingDialog) {
- VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
- }
+ OutOfTimeScreen(
+ uiState = state,
+ onSitePaymentClick = vm::onSitePaymentClick,
+ onRedeemVoucherClick = {
+ navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true }
+ },
+ onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } },
+ onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } },
+ onDisconnectClick = vm::onDisconnectClick,
+ onPurchaseBillingProductClick = { productId ->
+ navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true }
+ },
+ navigateToVerificationPendingDialog = {
+ navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true }
+ }
+ )
+}
- uiState.paymentDialogData?.let {
- PaymentDialog(
- paymentDialogData = uiState.paymentDialogData,
- retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
- onCloseDialog = onClosePurchaseResultDialog
- )
- }
+@Composable
+fun OutOfTimeScreen(
+ uiState: OutOfTimeUiState,
+ onDisconnectClick: () -> Unit = {},
+ onSitePaymentClick: () -> Unit = {},
+ onRedeemVoucherClick: () -> Unit = {},
+ onSettingsClick: () -> Unit = {},
+ onAccountClick: () -> Unit = {},
+ onPurchaseBillingProductClick: (ProductId) -> Unit = { _ -> },
+ navigateToVerificationPendingDialog: () -> Unit = {}
+) {
val scrollState = rememberScrollState()
ScaffoldWithTopBarAndDeviceName(
@@ -143,13 +185,6 @@ fun OutOfTimeScreen(
} else {
MaterialTheme.colorScheme.error
},
- statusBarColor =
- if (uiState.tunnelState.isSecured()) {
- MaterialTheme.colorScheme.inversePrimary
- } else {
- MaterialTheme.colorScheme.error
- },
- navigationBarColor = MaterialTheme.colorScheme.background,
iconTintColor =
if (uiState.tunnelState.isSecured()) {
MaterialTheme.colorScheme.onPrimary
@@ -191,7 +226,7 @@ fun OutOfTimeScreen(
text =
buildString {
append(stringResource(R.string.account_credit_has_expired))
- if (showSitePayment) {
+ if (uiState.showSitePayment) {
append(" ")
append(stringResource(R.string.add_time_to_account))
}
@@ -223,9 +258,9 @@ fun OutOfTimeScreen(
PlayPayment(
billingPaymentState = uiState.billingPaymentState,
onPurchaseBillingProductClick = { productId ->
- onPurchaseBillingProductClick(productId) { context as Activity }
+ onPurchaseBillingProductClick(productId)
},
- onInfoClick = { showVerificationPendingDialog = true },
+ onInfoClick = navigateToVerificationPendingDialog,
modifier =
Modifier.padding(
start = Dimens.sideMargin,
@@ -235,7 +270,7 @@ fun OutOfTimeScreen(
.align(Alignment.CenterHorizontally)
)
}
- if (showSitePayment) {
+ if (uiState.showSitePayment) {
SitePaymentButton(
onClick = onSitePaymentClick,
isEnabled = uiState.tunnelState.enableSitePaymentButton(),
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 02250c3663..b57e66c151 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
@@ -5,8 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -16,9 +15,11 @@ 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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -30,14 +31,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
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.transitions.DefaultTransition
import net.mullvad.mullvadvpn.compose.util.toDp
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
+import net.mullvad.mullvadvpn.ui.MainActivity
+import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -45,24 +56,41 @@ private fun PreviewPrivacyDisclaimerScreen() {
AppTheme { PrivacyDisclaimerScreen({}, {}) }
}
+@Destination(style = DefaultTransition::class)
+@Composable
+fun PrivacyDisclaimer(
+ navigator: DestinationsNavigator,
+) {
+ val viewModel: PrivacyDisclaimerViewModel = koinViewModel()
+
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ viewModel.uiSideEffect.collect {
+ when (it) {
+ PrivacyDisclaimerUiSideEffect.NavigateToLogin -> {
+ (context as MainActivity).initializeStateHandlerAndServiceConnection()
+ navigator.navigate(LoginDestination(null)) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ }
+ }
+ }
+ PrivacyDisclaimerScreen({}, viewModel::setPrivacyDisclosureAccepted)
+}
+
@Composable
fun PrivacyDisclaimerScreen(
onPrivacyPolicyLinkClicked: () -> Unit,
onAcceptClicked: () -> Unit,
) {
val topColor = MaterialTheme.colorScheme.primary
- ScaffoldWithTopBar(
- topBarColor = topColor,
- statusBarColor = topColor,
- navigationBarColor = MaterialTheme.colorScheme.background,
- onAccountClicked = null,
- onSettingsClicked = null
- ) {
+ ScaffoldWithTopBar(topBarColor = topColor, onAccountClicked = null, onSettingsClicked = null) {
ConstraintLayout(
modifier =
- Modifier.fillMaxHeight()
- .fillMaxWidth()
- .padding(it)
+ Modifier.padding(it)
+ .fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
) {
val (body, actionButtons) = createRefs()
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 0621c7ebcd..4763b21997 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,6 +14,9 @@ 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.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,20 +28,29 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
-import net.mullvad.mullvadvpn.compose.dialog.ReportProblemNoEmailDialog
+import net.mullvad.mullvadvpn.compose.destinations.ReportProblemNoEmailDialogDestination
+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.SecureScreenWhileInView
import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.ReportProblemSideEffect
import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState
+import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel
import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -50,22 +62,19 @@ private fun PreviewReportProblemScreen() {
@Composable
private fun PreviewReportProblemSendingScreen() {
AppTheme {
- ReportProblemScreen(uiState = ReportProblemUiState(false, SendingReportUiState.Sending))
+ ReportProblemScreen(
+ uiState = ReportProblemUiState(sendingState = SendingReportUiState.Sending),
+ )
}
}
@Preview
@Composable
-private fun PreviewReportProblemConfirmNoEmailScreen() {
- AppTheme { ReportProblemScreen(uiState = ReportProblemUiState(true)) }
-}
-
-@Preview
-@Composable
private fun PreviewReportProblemSuccessScreen() {
AppTheme {
ReportProblemScreen(
- uiState = ReportProblemUiState(false, SendingReportUiState.Success("email@mail.com"))
+ uiState =
+ ReportProblemUiState(sendingState = SendingReportUiState.Success("email@mail.com")),
)
}
}
@@ -77,37 +86,67 @@ private fun PreviewReportProblemErrorScreen() {
ReportProblemScreen(
uiState =
ReportProblemUiState(
- false,
- SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog)
+ sendingState =
+ SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog)
)
)
}
}
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun ReportProblem(
+ navigator: DestinationsNavigator,
+ noEmailConfirmResultRecipent: ResultRecipient<ReportProblemNoEmailDialogDestination, Boolean>
+) {
+ val vm = koinViewModel<ReportProblemViewModel>()
+ val uiState by vm.uiState.collectAsState()
+
+ LaunchedEffect(Unit) {
+ vm.uiSideEffect.collect {
+ when (it) {
+ is ReportProblemSideEffect.ShowConfirmNoEmail -> {
+ navigator.navigate(ReportProblemNoEmailDialogDestination)
+ }
+ }
+ }
+ }
+
+ noEmailConfirmResultRecipent.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {}
+ is NavResult.Value -> vm.sendReport(uiState.email, uiState.description, true)
+ }
+ }
+
+ ReportProblemScreen(
+ uiState,
+ onSendReport = { vm.sendReport(uiState.email, uiState.description) },
+ onClearSendResult = vm::clearSendResult,
+ onNavigateToViewLogs = {
+ navigator.navigate(ViewLogsDestination()) { launchSingleTop = true }
+ },
+ onEmailChanged = vm::updateEmail,
+ onDescriptionChanged = vm::updateDescription,
+ onBackClick = navigator::navigateUp,
+ )
+}
+
@Composable
-fun ReportProblemScreen(
+private fun ReportProblemScreen(
uiState: ReportProblemUiState,
- onSendReport: (String, String) -> Unit = { _, _ -> },
- onDismissNoEmailDialog: () -> Unit = {},
+ onSendReport: () -> Unit = {},
onClearSendResult: () -> Unit = {},
onNavigateToViewLogs: () -> Unit = {},
- updateEmail: (String) -> Unit = {},
- updateDescription: (String) -> Unit = {},
+ onEmailChanged: (String) -> Unit = {},
+ onDescriptionChanged: (String) -> Unit = {},
onBackClick: () -> Unit = {}
) {
- // Dialog to show confirm if no email was added
- if (uiState.showConfirmNoEmail) {
- ReportProblemNoEmailDialog(
- onDismiss = onDismissNoEmailDialog,
- onConfirm = { onSendReport(uiState.email, uiState.description) }
- )
- }
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.report_a_problem),
navigationIcon = { NavigateBackIconButton(onBackClick) }
) { modifier ->
-
// Show sending states
if (uiState.sendingState != null) {
Column(
@@ -119,11 +158,7 @@ fun ReportProblemScreen(
) {
when (uiState.sendingState) {
SendingReportUiState.Sending -> SendingContent()
- is SendingReportUiState.Error ->
- ErrorContent(
- { onSendReport(uiState.email, uiState.description) },
- onClearSendResult
- )
+ is SendingReportUiState.Error -> ErrorContent(onSendReport, onClearSendResult)
is SendingReportUiState.Success -> SentContent(uiState.sendingState)
}
return@ScaffoldWithMediumTopBar
@@ -146,7 +181,7 @@ fun ReportProblemScreen(
TextField(
modifier = Modifier.fillMaxWidth(),
value = uiState.email,
- onValueChange = updateEmail,
+ onValueChange = onEmailChanged,
maxLines = 1,
singleLine = true,
placeholder = { Text(text = stringResource(id = R.string.user_email_hint)) },
@@ -156,7 +191,7 @@ fun ReportProblemScreen(
TextField(
modifier = Modifier.fillMaxWidth().weight(1f),
value = uiState.description,
- onValueChange = updateDescription,
+ onValueChange = onDescriptionChanged,
placeholder = { Text(stringResource(R.string.user_message_hint)) },
colors = mullvadWhiteTextFieldColors()
)
@@ -168,7 +203,7 @@ fun ReportProblemScreen(
)
Spacer(modifier = Modifier.height(Dimens.buttonSpacing))
VariantButton(
- onClick = { onSendReport(uiState.email, uiState.description) },
+ onClick = onSendReport,
isEnabled = uiState.description.isNotEmpty(),
text = stringResource(id = R.string.send)
)
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 5bfdee94f6..d113ca258d 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
@@ -1,13 +1,10 @@
package net.mullvad.mullvadvpn.compose.screen
import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -18,17 +15,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.remember
+import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusProperties
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -38,9 +32,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.HtmlCompat
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.FilterCell
import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell
@@ -48,15 +41,20 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
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.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.relaylist.RelayCountry
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect
+import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -72,18 +70,37 @@ private fun PreviewSelectLocationScreen() {
AppTheme {
SelectLocationScreen(
uiState = state,
- uiCloseAction = MutableSharedFlow(),
- enterTransitionEndAction = MutableSharedFlow()
)
}
}
-@OptIn(ExperimentalComposeUiApi::class)
+@Destination(style = SelectLocationTransition::class)
+@Composable
+fun SelectLocation(navigator: DestinationsNavigator) {
+ val vm = koinViewModel<SelectLocationViewModel>()
+ val state = vm.uiState.collectAsState().value
+ LaunchedEffect(Unit) {
+ vm.uiSideEffect.collect {
+ when (it) {
+ SelectLocationSideEffect.CloseScreen -> navigator.navigateUp()
+ }
+ }
+ }
+
+ SelectLocationScreen(
+ uiState = state,
+ onSelectRelay = vm::selectRelay,
+ onSearchTermInput = vm::onSearchTermInput,
+ onBackClick = navigator::navigateUp,
+ onFilterClick = { navigator.navigate(FilterScreenDestination) },
+ removeOwnershipFilter = vm::removeOwnerFilter,
+ removeProviderFilter = vm::removeProviderFilter
+ )
+}
+
@Composable
fun SelectLocationScreen(
uiState: SelectLocationUiState,
- uiCloseAction: SharedFlow<Unit>,
- enterTransitionEndAction: SharedFlow<Unit>,
onSelectRelay: (item: RelayItem) -> Unit = {},
onSearchTermInput: (searchTerm: String) -> Unit = {},
onBackClick: () -> Unit = {},
@@ -91,143 +108,131 @@ fun SelectLocationScreen(
removeOwnershipFilter: () -> Unit = {},
removeProviderFilter: () -> Unit = {}
) {
-
val backgroundColor = MaterialTheme.colorScheme.background
- val systemUiController = rememberSystemUiController()
- LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } }
- LaunchedEffect(Unit) {
- enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) }
- }
-
- val (backFocus, listFocus, searchBarFocus) = remember { FocusRequester.createRefs() }
- Column(modifier = Modifier.background(backgroundColor).fillMaxWidth().fillMaxHeight()) {
- Row(
- modifier =
- Modifier.padding(vertical = Dimens.selectLocationTitlePadding)
- .padding(end = Dimens.selectLocationTitlePadding)
- .fillMaxWidth()
- ) {
- IconButton(onClick = onBackClick) {
- Icon(
- painter = painterResource(id = R.drawable.icon_back),
- contentDescription = null,
- tint = Color.Unspecified,
- modifier = Modifier.size(Dimens.titleIconSize).rotate(270f)
+ Scaffold {
+ Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ modifier = Modifier.rotate(270f),
+ painter = painterResource(id = R.drawable.icon_back),
+ tint = Color.Unspecified,
+ contentDescription = null,
+ )
+ }
+ Text(
+ text = stringResource(id = R.string.select_location),
+ modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onPrimary
)
- }
- Text(
- text = stringResource(id = R.string.select_location),
- modifier =
- Modifier.align(Alignment.CenterVertically)
- .weight(weight = 1f)
- .padding(end = Dimens.titleIconSize),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onPrimary
- )
- Image(
- painter = painterResource(id = R.drawable.icons_more_circle),
- contentDescription = null,
- modifier = Modifier.size(Dimens.titleIconSize).clickable { onFilterClick() }
- )
- }
- when (uiState) {
- SelectLocationUiState.Loading -> {}
- is SelectLocationUiState.ShowData -> {
- if (uiState.hasFilter) {
- FilterCell(
- ownershipFilter = uiState.selectedOwnership,
- selectedProviderFilter = uiState.selectedProvidersCount,
- removeOwnershipFilter = removeOwnershipFilter,
- removeProviderFilter = removeProviderFilter
+ IconButton(onClick = onFilterClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.icons_more_circle),
+ contentDescription = null,
+ tint = Color.Unspecified,
)
}
}
- }
- SearchTextField(
- modifier =
- Modifier.fillMaxWidth()
- .focusRequester(searchBarFocus)
- .focusProperties { next = backFocus }
- .height(Dimens.searchFieldHeight)
- .padding(horizontal = Dimens.searchFieldHorizontalPadding)
- ) { searchString ->
- onSearchTermInput.invoke(searchString)
- }
- Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
- val lazyListState = rememberLazyListState()
- LazyColumn(
- modifier =
- Modifier.focusRequester(listFocus)
- .fillMaxSize()
- .drawVerticalScrollbar(
- lazyListState,
- MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
- ),
- state = lazyListState,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
when (uiState) {
- SelectLocationUiState.Loading -> {
- item(contentType = ContentType.PROGRESS) {
- MullvadCircularProgressIndicatorLarge(
- Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ SelectLocationUiState.Loading -> {}
+ is SelectLocationUiState.ShowData -> {
+ if (uiState.hasFilter) {
+ FilterCell(
+ ownershipFilter = uiState.selectedOwnership,
+ selectedProviderFilter = uiState.selectedProvidersCount,
+ removeOwnershipFilter = removeOwnershipFilter,
+ removeProviderFilter = removeProviderFilter
)
}
}
- is SelectLocationUiState.ShowData -> {
- if (uiState.countries.isEmpty()) {
- item(contentType = ContentType.EMPTY_TEXT) {
- val firstRow =
- HtmlCompat.fromHtml(
- textResource(
- id = R.string.select_location_empty_text_first_row,
- uiState.searchTerm
+ }
+
+ SearchTextField(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(Dimens.searchFieldHeight)
+ .padding(horizontal = Dimens.searchFieldHorizontalPadding)
+ ) { searchString ->
+ onSearchTermInput.invoke(searchString)
+ }
+ Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
+ val lazyListState = rememberLazyListState()
+ LazyColumn(
+ modifier =
+ Modifier.fillMaxSize()
+ .drawVerticalScrollbar(
+ lazyListState,
+ MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ ),
+ state = lazyListState,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ when (uiState) {
+ SelectLocationUiState.Loading -> {
+ item(contentType = ContentType.PROGRESS) {
+ MullvadCircularProgressIndicatorLarge(
+ Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+ }
+ is SelectLocationUiState.ShowData -> {
+ if (uiState.countries.isEmpty()) {
+ item(contentType = ContentType.EMPTY_TEXT) {
+ val firstRow =
+ HtmlCompat.fromHtml(
+ textResource(
+ id = R.string.select_location_empty_text_first_row,
+ uiState.searchTerm
+ ),
+ HtmlCompat.FROM_HTML_MODE_COMPACT
+ )
+ .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
+ val secondRow =
+ textResource(
+ id = R.string.select_location_empty_text_second_row
+ )
+ Column(
+ modifier =
+ Modifier.padding(
+ horizontal = Dimens.selectLocationTitlePadding
),
- HtmlCompat.FROM_HTML_MODE_COMPACT
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = firstRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
)
- .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
- val secondRow =
- textResource(id = R.string.select_location_empty_text_second_row)
- Column(
- modifier =
- Modifier.padding(
- horizontal = Dimens.selectLocationTitlePadding
- ),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = firstRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = secondRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary
+ Text(
+ text = secondRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+ }
+ } else {
+ items(
+ count = uiState.countries.size,
+ key = { index -> uiState.countries[index].hashCode() },
+ contentType = { ContentType.ITEM }
+ ) { index ->
+ val country = uiState.countries[index]
+ RelayLocationCell(
+ relay = country,
+ selectedItem = uiState.selectedRelay,
+ onSelectRelay = onSelectRelay,
+ modifier = Modifier.animateContentSize()
)
}
}
- } else {
- items(
- count = uiState.countries.size,
- key = { index -> uiState.countries[index].hashCode() },
- contentType = { ContentType.ITEM }
- ) { index ->
- val country = uiState.countries[index]
- RelayLocationCell(
- relay = country,
- selectedItem = uiState.selectedRelay,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
}
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
index b092ed981b..d057da60f8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
@@ -11,29 +11,35 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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 com.google.accompanist.systemuicontroller.rememberSystemUiController
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.DefaultExternalLinkView
import net.mullvad.mullvadvpn.compose.cell.NavigationCellBody
import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ReportProblemDestination
+import net.mullvad.mullvadvpn.compose.destinations.SplitTunnelingDestination
+import net.mullvad.mullvadvpn.compose.destinations.VpnSettingsDestination
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
import net.mullvad.mullvadvpn.compose.state.SettingsUiState
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG
+import net.mullvad.mullvadvpn.compose.transitions.SettingsTransition
import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.lib.common.util.openLink
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild
+import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
+import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@@ -47,29 +53,41 @@ private fun PreviewSettings() {
isLoggedIn = true,
isUpdateAvailable = true
),
- enterTransitionEndAction = MutableSharedFlow()
)
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Destination(style = SettingsTransition::class)
+@Composable
+fun Settings(navigator: DestinationsNavigator) {
+ val vm = koinViewModel<SettingsViewModel>()
+ val state by vm.uiState.collectAsState()
+ SettingsScreen(
+ uiState = state,
+ onVpnSettingCellClick = {
+ navigator.navigate(VpnSettingsDestination) { launchSingleTop = true }
+ },
+ onSplitTunnelingCellClick = {
+ navigator.navigate(SplitTunnelingDestination) { launchSingleTop = true }
+ },
+ onReportProblemCellClick = {
+ navigator.navigate(ReportProblemDestination) { launchSingleTop = true }
+ },
+ onBackClick = navigator::navigateUp
+ )
+}
+
@ExperimentalMaterial3Api
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
- enterTransitionEndAction: SharedFlow<Unit>,
onVpnSettingCellClick: () -> Unit = {},
onSplitTunnelingCellClick: () -> Unit = {},
onReportProblemCellClick: () -> Unit = {},
onBackClick: () -> Unit = {}
) {
val context = LocalContext.current
- val backgroundColor = MaterialTheme.colorScheme.background
- val systemUiController = rememberSystemUiController()
-
- LaunchedEffect(Unit) {
- systemUiController.setNavigationBarColor(backgroundColor)
- enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) }
- }
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.settings),
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
new file mode 100644
index 0000000000..0252c8129d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt
@@ -0,0 +1,139 @@
+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
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+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
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootNavGraph
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination
+import net.mullvad.mullvadvpn.compose.destinations.DeviceRevokedDestination
+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.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.viewmodel.SplashUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.SplashViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewLoadingScreen() {
+ AppTheme { SplashScreen() }
+}
+
+// Set this as the start destination of the default nav graph
+@RootNavGraph(start = true)
+@Destination(style = DefaultTransition::class)
+@Composable
+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 }
+ }
+ }
+ SplashUiSideEffect.NavigateToLogin -> {
+ navigator.navigate(LoginDestination()) {
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ SplashUiSideEffect.NavigateToPrivacyDisclaimer -> {
+ navigator.navigate(PrivacyDisclaimerDestination) { popUpTo(NavGraphs.root) {} }
+ }
+ SplashUiSideEffect.NavigateToRevoked -> {
+ navigator.navigate(DeviceRevokedDestination) {
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ SplashUiSideEffect.NavigateToOutOfTime ->
+ navigator.navigate(OutOfTimeDestination) {
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) { viewModel.start() }
+
+ SplashScreen()
+}
+
+@Composable
+fun SplashScreen() {
+
+ val backgroundColor = MaterialTheme.colorScheme.primary
+
+ ScaffoldWithTopBar(
+ topBarColor = backgroundColor,
+ onSettingsClicked = null,
+ onAccountClicked = null,
+ isIconAndLogoVisible = false,
+ content = {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier.background(backgroundColor)
+ .padding(it)
+ .padding(bottom = it.calculateTopPadding())
+ .fillMaxSize()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.launch_logo),
+ contentDescription = "",
+ modifier = Modifier.size(Dimens.splashLogoSize)
+ )
+ Image(
+ painter = painterResource(id = R.drawable.logo_text),
+ contentDescription = "",
+ alpha = 0.6f,
+ modifier =
+ Modifier.padding(top = Dimens.mediumPadding)
+ .height(Dimens.splashLogoTextHeight)
+ )
+ Text(
+ text = stringResource(id = R.string.connecting_to_daemon),
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ MaterialTheme.colorScheme.onPrimary
+ .copy(alpha = AlphaDescription)
+ .compositeOver(backgroundColor),
+ modifier = Modifier.padding(top = Dimens.mediumPadding)
+ )
+ }
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
index a1f9bd8a97..3396739491 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
@@ -13,12 +13,19 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.graphics.drawable.toBitmapOrNull
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.applist.AppData
import net.mullvad.mullvadvpn.compose.cell.BaseCell
@@ -32,8 +39,11 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType
import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -69,6 +79,25 @@ private fun PreviewSplitTunnelingScreen() {
}
}
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun SplitTunneling(navigator: DestinationsNavigator) {
+ val viewModel = koinViewModel<SplitTunnelingViewModel>()
+ val state by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ val packageManager = remember(context) { context.packageManager }
+ SplitTunnelingScreen(
+ uiState = state,
+ onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
+ onExcludeAppClick = viewModel::onExcludeAppClick,
+ onIncludeAppClick = viewModel::onIncludeAppClick,
+ onBackClick = navigator::navigateUp,
+ onResolveIcon = { packageName ->
+ packageManager.getApplicationIcon(packageName).toBitmapOrNull()
+ }
+ )
+}
+
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun SplitTunnelingScreen(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt
index 7ff8aa11aa..ef4e58cda1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -26,6 +27,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
@@ -33,12 +36,15 @@ import net.mullvad.mullvadvpn.compose.component.MullvadMediumTopBar
import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.provider.getLogsShareIntent
import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState
+import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -52,6 +58,14 @@ private fun PreviewViewLogsLoadingScreen() {
AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) }
}
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun ViewLogs(navigator: DestinationsNavigator) {
+ val vm = koinViewModel<ViewLogsViewModel>()
+ val uiState = vm.uiState.collectAsState()
+ ViewLogsScreen(uiState = uiState.value, onBackClick = navigator::navigateUp)
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewLogsScreen(
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 7290b9600f..765975a446 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -11,17 +10,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
+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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -29,11 +30,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.LifecycleOwner
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.BaseCell
import net.mullvad.mullvadvpn.compose.cell.ContentBlockersDisableModeCellSubtitle
@@ -50,18 +51,20 @@ import net.mullvad.mullvadvpn.compose.cell.SelectableCell
import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
-import net.mullvad.mullvadvpn.compose.dialog.ContentBlockersInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.CustomDnsInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.CustomPortDialog
-import net.mullvad.mullvadvpn.compose.dialog.DnsDialog
-import net.mullvad.mullvadvpn.compose.dialog.LocalNetworkSharingInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.MalwareInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.MtuDialog
-import net.mullvad.mullvadvpn.compose.dialog.ObfuscationInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.QuantumResistanceInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.WireguardPortInfoDialog
+import net.mullvad.mullvadvpn.compose.destinations.ContentBlockersInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.CustomDnsInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.DnsDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.LocalNetworkSharingInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.MalwareInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.MtuDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.ObfuscationInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.QuantumResistanceInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.UdpOverTcpPortInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.WireguardCustomPortDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.WireguardPortInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.dialog.WireguardCustomPortNavArgs
+import net.mullvad.mullvadvpn.compose.dialog.WireguardPortInfoDialogArgument
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog
import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_LAST_ITEM_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG
@@ -70,17 +73,22 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG
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.constant.WIREGUARD_PRESET_PORTS
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.model.Constraint
import net.mullvad.mullvadvpn.model.Port
+import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.model.QuantumResistantState
import net.mullvad.mullvadvpn.model.SelectedObfuscation
import net.mullvad.mullvadvpn.util.hasValue
import net.mullvad.mullvadvpn.util.isCustom
-import net.mullvad.mullvadvpn.util.toDisplayCustomPort
+import net.mullvad.mullvadvpn.util.toValueOrNull
import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
+import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect
+import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -94,179 +102,187 @@ private fun PreviewVpnSettings() {
isCustomDnsEnabled = true,
customDnsItems = listOf(CustomDnsItem("0.0.0.0", false)),
),
- onMtuCellClick = {},
- onSaveMtuClick = {},
- onRestoreMtuClick = {},
- onCancelMtuDialogClick = {},
- onToggleAutoConnect = {},
- onToggleLocalNetworkSharing = {},
- onToggleDnsClick = {},
- onToggleBlockAds = {},
+ snackbarHostState = SnackbarHostState(),
onToggleBlockTrackers = {},
+ onToggleBlockAds = {},
onToggleBlockMalware = {},
+ onToggleAutoConnect = {},
+ onToggleLocalNetworkSharing = {},
onToggleBlockAdultContent = {},
onToggleBlockGambling = {},
onToggleBlockSocialMedia = {},
- onDnsClick = {},
- onDnsInputChange = {},
- onSaveDnsClick = {},
- onRemoveDnsClick = {},
- onCancelDnsDialogClick = {},
- onLocalNetworkSharingInfoClick = {},
- onContentsBlockersInfoClick = {},
- onMalwareInfoClick = {},
- onCustomDnsInfoClick = {},
- onDismissInfoClick = {},
+ navigateToMtuDialog = {},
+ navigateToDns = { _, _ -> },
+ onToggleDnsClick = {},
onBackClick = {},
- toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow(),
- onStopEvent = {},
onSelectObfuscationSetting = {},
- onObfuscationInfoClick = {},
onSelectQuantumResistanceSetting = {},
- onQuantumResistanceInfoClicked = {},
onWireguardPortSelected = {},
- onWireguardPortInfoClicked = {},
- onShowCustomPortDialog = {},
- onCancelCustomPortDialogClick = {},
- onCloseCustomPortDialog = {}
)
}
}
-@OptIn(ExperimentalFoundationApi::class)
+@Destination(style = SlideInFromRightTransition::class)
@Composable
-fun VpnSettingsScreen(
- lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
- uiState: VpnSettingsUiState,
- onMtuCellClick: () -> Unit = {},
- onSaveMtuClick: (Int) -> Unit = {},
- onRestoreMtuClick: () -> Unit = {},
- onCancelMtuDialogClick: () -> Unit = {},
- onToggleAutoConnect: (Boolean) -> Unit = {},
- onToggleLocalNetworkSharing: (Boolean) -> Unit = {},
- onToggleDnsClick: (Boolean) -> Unit = {},
- onToggleBlockAds: (Boolean) -> Unit = {},
- onToggleBlockTrackers: (Boolean) -> Unit = {},
- onToggleBlockMalware: (Boolean) -> Unit = {},
- onToggleBlockAdultContent: (Boolean) -> Unit = {},
- onToggleBlockGambling: (Boolean) -> Unit = {},
- onToggleBlockSocialMedia: (Boolean) -> Unit = {},
- onDnsClick: (index: Int?) -> Unit = {},
- onDnsInputChange: (String) -> Unit = {},
- onSaveDnsClick: () -> Unit = {},
- onRemoveDnsClick: () -> Unit = {},
- onCancelDnsDialogClick: () -> Unit = {},
- onLocalNetworkSharingInfoClick: () -> Unit = {},
- onContentsBlockersInfoClick: () -> Unit = {},
- onMalwareInfoClick: () -> Unit = {},
- onCustomDnsInfoClick: () -> Unit = {},
- onDismissInfoClick: () -> Unit = {},
- onBackClick: () -> Unit = {},
- onStopEvent: () -> Unit = {},
- toastMessagesSharedFlow: SharedFlow<String>,
- onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {},
- onObfuscationInfoClick: () -> Unit = {},
- onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {},
- onQuantumResistanceInfoClicked: () -> Unit = {},
- onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {},
- onWireguardPortInfoClicked: () -> Unit = {},
- onShowCustomPortDialog: () -> Unit = {},
- onCancelCustomPortDialogClick: () -> Unit = {},
- onCloseCustomPortDialog: () -> Unit = {}
+fun VpnSettings(
+ navigator: DestinationsNavigator,
+ dnsDialogResult: ResultRecipient<DnsDialogDestination, Boolean>,
+ customWgPortResult: ResultRecipient<WireguardCustomPortDialogDestination, Int?>
) {
- val savedCustomPort = rememberSaveable { mutableStateOf<Constraint<Port>>(Constraint.Any()) }
+ val vm = koinViewModel<VpnSettingsViewModel>()
+ val state = vm.uiState.collectAsState().value
- when (val dialog = uiState.dialog) {
- is VpnSettingsDialog.Mtu -> {
- MtuDialog(
- mtuInitial = dialog.mtuEditValue.toIntOrNull(),
- onSave = { onSaveMtuClick(it) },
- onRestoreDefaultValue = { onRestoreMtuClick() },
- onDismiss = { onCancelMtuDialogClick() }
- )
- }
- is VpnSettingsDialog.Dns -> {
- DnsDialog(
- stagedDns = dialog.stagedDns,
- isAllowLanEnabled = uiState.isAllowLanEnabled,
- onIpAddressChanged = { onDnsInputChange(it) },
- onAttemptToSave = { onSaveDnsClick() },
- onRemove = { onRemoveDnsClick() },
- onDismiss = { onCancelDnsDialogClick() }
- )
- }
- is VpnSettingsDialog.LocalNetworkSharingInfo -> {
- LocalNetworkSharingInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.ContentBlockersInfo -> {
- ContentBlockersInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.CustomDnsInfo -> {
- CustomDnsInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.MalwareInfo -> {
- MalwareInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.ObfuscationInfo -> {
- ObfuscationInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.QuantumResistanceInfo -> {
- QuantumResistanceInfoDialog(onDismissInfoClick)
- }
- is VpnSettingsDialog.WireguardPortInfo -> {
- WireguardPortInfoDialog(dialog.availablePortRanges, onDismissInfoClick)
- }
- is VpnSettingsDialog.CustomPort -> {
- CustomPortDialog(
- customPort = savedCustomPort.value.toDisplayCustomPort(),
- allowedPortRanges = dialog.availablePortRanges,
- onSave = { customPortString ->
- onWireguardPortSelected(Constraint.Only(Port(customPortString.toInt())))
- },
- onReset = {
- if (uiState.selectedWireguardPort.isCustom()) {
- onWireguardPortSelected(Constraint.Any())
- }
- savedCustomPort.value = Constraint.Any()
- onCloseCustomPortDialog()
- },
- showReset = savedCustomPort.value is Constraint.Only,
- onDismissRequest = { onCancelCustomPortDialogClick() }
- )
+ dnsDialogResult.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ vm.onDnsDialogDismissed()
+ }
+ is NavResult.Value -> {}
}
}
- var expandContentBlockersState by rememberSaveable { mutableStateOf(false) }
- val biggerPadding = 54.dp
- val topPadding = 6.dp
+ customWgPortResult.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {}
+ is NavResult.Value -> {
+ val port = it.value
- LaunchedEffect(uiState.selectedWireguardPort) {
- if (
- uiState.selectedWireguardPort.isCustom() &&
- uiState.selectedWireguardPort != savedCustomPort.value
- ) {
- savedCustomPort.value = uiState.selectedWireguardPort
+ if (port != null) {
+ vm.onWireguardPortSelected(Constraint.Only(Port(port)))
+ } else {
+ vm.resetCustomPort()
+ }
+ }
}
}
- val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
- toastMessagesSharedFlow.distinctUntilChanged().collect { message ->
- Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ 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 }
+ }
}
}
+
+ val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
- onStopEvent()
+ vm.onStopEvent()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
+
+ VpnSettingsScreen(
+ uiState = state,
+ snackbarHostState = snackbarHostState,
+ navigateToContentBlockersInfo = {
+ navigator.navigate(ContentBlockersInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateToCustomDnsInfo = {
+ navigator.navigate(CustomDnsInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateToMalwareInfo = {
+ navigator.navigate(MalwareInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateToObfuscationInfo = {
+ navigator.navigate(ObfuscationInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateToQuantumResistanceInfo = {
+ navigator.navigate(QuantumResistanceInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateUdp2TcpInfo = {
+ navigator.navigate(UdpOverTcpPortInfoDialogDestination) { launchSingleTop = true }
+ },
+ navigateToWireguardPortInfo = {
+ navigator.navigate(
+ WireguardPortInfoDialogDestination(WireguardPortInfoDialogArgument(it))
+ ) {
+ launchSingleTop = true
+ }
+ },
+ navigateToLocalNetworkSharingInfo = {
+ navigator.navigate(LocalNetworkSharingInfoDialogDestination) { launchSingleTop = true }
+ },
+ onToggleBlockTrackers = vm::onToggleBlockTrackers,
+ onToggleBlockAds = vm::onToggleBlockAds,
+ onToggleBlockMalware = vm::onToggleBlockMalware,
+ onToggleAutoConnect = vm::onToggleAutoConnect,
+ onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing,
+ onToggleBlockAdultContent = vm::onToggleBlockAdultContent,
+ onToggleBlockGambling = vm::onToggleBlockGambling,
+ onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia,
+ navigateToMtuDialog = {
+ navigator.navigate(MtuDialogDestination(it)) { launchSingleTop = true }
+ },
+ navigateToDns = { index, address ->
+ navigator.navigate(DnsDialogDestination(index, address)) { launchSingleTop = true }
+ },
+ navigateToWireguardPortDialog = {
+ val args =
+ WireguardCustomPortNavArgs(
+ state.customWireguardPort?.toValueOrNull(),
+ state.availablePortRanges
+ )
+ navigator.navigate(WireguardCustomPortDialogDestination(args)) {
+ launchSingleTop = true
+ }
+ },
+ onToggleDnsClick = vm::onToggleCustomDns,
+ onBackClick = navigator::navigateUp,
+ onSelectObfuscationSetting = vm::onSelectObfuscationSetting,
+ onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting,
+ onWireguardPortSelected = vm::onWireguardPortSelected,
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun VpnSettingsScreen(
+ uiState: VpnSettingsUiState,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ navigateToContentBlockersInfo: () -> Unit = {},
+ navigateToCustomDnsInfo: () -> Unit = {},
+ navigateToMalwareInfo: () -> Unit = {},
+ navigateToObfuscationInfo: () -> Unit = {},
+ navigateToQuantumResistanceInfo: () -> Unit = {},
+ navigateUdp2TcpInfo: () -> Unit = {},
+ navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {},
+ navigateToLocalNetworkSharingInfo: () -> Unit = {},
+ navigateToWireguardPortDialog: () -> Unit = {},
+ onToggleBlockTrackers: (Boolean) -> Unit = {},
+ onToggleBlockAds: (Boolean) -> Unit = {},
+ onToggleBlockMalware: (Boolean) -> Unit = {},
+ onToggleAutoConnect: (Boolean) -> Unit = {},
+ onToggleLocalNetworkSharing: (Boolean) -> Unit = {},
+ onToggleBlockAdultContent: (Boolean) -> Unit = {},
+ onToggleBlockGambling: (Boolean) -> Unit = {},
+ onToggleBlockSocialMedia: (Boolean) -> Unit = {},
+ navigateToMtuDialog: (mtu: Int?) -> Unit = {},
+ navigateToDns: (index: Int?, address: String?) -> Unit = { _, _ -> },
+ onToggleDnsClick: (Boolean) -> Unit = {},
+ onBackClick: () -> Unit = {},
+ onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {},
+ onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {},
+ onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {},
+) {
+ var expandContentBlockersState by rememberSaveable { mutableStateOf(false) }
+ val biggerPadding = 54.dp
+ val topPadding = 6.dp
+
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.settings_vpn),
navigationIcon = { NavigateBackIconButton(onBackClick) },
+ snackbarHostState = snackbarHostState
) { modifier, lazyListState ->
LazyColumn(
modifier = modifier.testTag(LAZY_LIST_TEST_TAG).animateContentSize(),
@@ -288,10 +304,10 @@ fun VpnSettingsScreen(
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
HeaderSwitchComposeCell(
title = stringResource(R.string.local_network_sharing),
- isToggled = uiState.isAllowLanEnabled,
+ isToggled = uiState.isLocalNetworkSharingEnabled,
isEnabled = true,
onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) },
- onInfoClicked = { onLocalNetworkSharingInfoClick() }
+ onInfoClicked = navigateToLocalNetworkSharingInfo
)
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
}
@@ -301,7 +317,7 @@ fun VpnSettingsScreen(
title = stringResource(R.string.dns_content_blockers_title),
isExpanded = expandContentBlockersState,
isEnabled = !uiState.isCustomDnsEnabled,
- onInfoClicked = { onContentsBlockersInfoClick() },
+ onInfoClicked = { navigateToContentBlockersInfo() },
onCellClicked = { expandContentBlockersState = !expandContentBlockersState }
)
}
@@ -333,7 +349,7 @@ fun VpnSettingsScreen(
isToggled = uiState.contentBlockersOptions.blockMalware,
isEnabled = !uiState.isCustomDnsEnabled,
onCellClicked = { onToggleBlockMalware(it) },
- onInfoClicked = { onMalwareInfoClick() },
+ onInfoClicked = { navigateToMalwareInfo() },
background = MaterialTheme.colorScheme.secondaryContainer,
startPadding = Dimens.indentedCellStartPadding
)
@@ -391,7 +407,7 @@ fun VpnSettingsScreen(
isToggled = uiState.isCustomDnsEnabled,
isEnabled = uiState.contentBlockersOptions.isAnyBlockerEnabled().not(),
onCellClicked = { newValue -> onToggleDnsClick(newValue) },
- onInfoClicked = { onCustomDnsInfoClick() }
+ onInfoClicked = { navigateToCustomDnsInfo() }
)
}
@@ -400,8 +416,8 @@ fun VpnSettingsScreen(
DnsCell(
address = item.address,
isUnreachableLocalDnsWarningVisible =
- item.isLocal && uiState.isAllowLanEnabled.not(),
- onClick = { onDnsClick(index) },
+ item.isLocal && !uiState.isLocalNetworkSharingEnabled,
+ onClick = { navigateToDns(index, item.address) },
modifier = Modifier.animateItemPlacement()
)
Divider()
@@ -409,7 +425,7 @@ fun VpnSettingsScreen(
itemWithDivider {
BaseCell(
- onCellClicked = { onDnsClick(null) },
+ onCellClicked = { navigateToDns(null, null) },
title = {
Text(
text = stringResource(id = R.string.add_a_server),
@@ -441,7 +457,8 @@ fun VpnSettingsScreen(
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
InformationComposeCell(
title = stringResource(id = R.string.wireguard_port_title),
- onInfoClicked = onWireguardPortInfoClicked
+ onInfoClicked = { navigateToWireguardPortInfo(uiState.availablePortRanges) },
+ onCellClicked = { navigateToWireguardPortInfo(uiState.availablePortRanges) },
)
}
@@ -468,20 +485,15 @@ fun VpnSettingsScreen(
CustomPortCell(
title = stringResource(id = R.string.wireguard_custon_port_title),
isSelected = uiState.selectedWireguardPort.isCustom(),
- port =
- if (uiState.selectedWireguardPort.isCustom()) {
- uiState.selectedWireguardPort.toDisplayCustomPort()
- } else {
- savedCustomPort.value.toDisplayCustomPort()
- },
+ port = uiState.customWireguardPort?.toValueOrNull(),
onMainCellClicked = {
- if (savedCustomPort.value is Constraint.Only) {
- onWireguardPortSelected(savedCustomPort.value)
+ if (uiState.customWireguardPort != null) {
+ onWireguardPortSelected(uiState.customWireguardPort)
} else {
- onShowCustomPortDialog()
+ navigateToWireguardPortDialog()
}
},
- onPortCellClicked = { onShowCustomPortDialog() },
+ onPortCellClicked = navigateToWireguardPortDialog,
mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG,
numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG
)
@@ -491,7 +503,8 @@ fun VpnSettingsScreen(
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
InformationComposeCell(
title = stringResource(R.string.obfuscation_title),
- onInfoClicked = { onObfuscationInfoClick() }
+ onInfoClicked = navigateToObfuscationInfo,
+ onCellClicked = navigateToObfuscationInfo
)
}
itemWithDivider {
@@ -520,7 +533,8 @@ fun VpnSettingsScreen(
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
InformationComposeCell(
title = stringResource(R.string.quantum_resistant_title),
- onInfoClicked = { onQuantumResistanceInfoClicked() }
+ onInfoClicked = navigateToQuantumResistanceInfo,
+ onCellClicked = navigateToQuantumResistanceInfo
)
}
itemWithDivider {
@@ -548,7 +562,12 @@ fun VpnSettingsScreen(
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
}
- item { MtuComposeCell(mtuValue = uiState.mtu, onEditMtu = { onMtuCellClick() }) }
+ item {
+ MtuComposeCell(
+ mtuValue = uiState.mtu,
+ onEditMtu = { navigateToMtuDialog(uiState.mtu.toIntOrNull()) }
+ )
+ }
item {
MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
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 4778756648..cc8cf4977c 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.compose.screen
-import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -19,10 +18,9 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -30,21 +28,29 @@ 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 kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.popUpTo
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.NavGraphs
import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton
import net.mullvad.mullvadvpn.compose.button.SitePaymentButton
import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton
import net.mullvad.mullvadvpn.compose.component.PlayPayment
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
-import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
-import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
+import net.mullvad.mullvadvpn.compose.destinations.AccountDestination
+import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination
+import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination
+import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination
+import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination
+import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination
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.createCopyToClipboardHandle
import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces
import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
@@ -57,13 +63,13 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
import net.mullvad.mullvadvpn.lib.theme.color.MullvadWhite
import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
private fun PreviewWelcomeScreen() {
AppTheme {
WelcomeScreen(
- showSitePayment = true,
uiState =
WelcomeUiState(
accountNumber = "4444555566667777",
@@ -76,55 +82,98 @@ private fun PreviewWelcomeScreen() {
)
)
),
- uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {},
- onPurchaseBillingProductClick = { _, _ -> },
- onClosePurchaseResultDialog = {}
+ onPurchaseBillingProductClick = { _ -> },
+ navigateToDeviceInfoDialog = {},
+ navigateToVerificationPendingDialog = {}
)
}
}
+@Destination(style = HomeTransition::class)
@Composable
-fun WelcomeScreen(
- showSitePayment: Boolean,
- uiState: WelcomeUiState,
- uiSideEffect: SharedFlow<WelcomeViewModel.UiSideEffect>,
- onSitePaymentClick: () -> Unit,
- onRedeemVoucherClick: () -> Unit,
- onSettingsClick: () -> Unit,
- onAccountClick: () -> Unit,
- openConnectScreen: () -> Unit,
- onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit,
- onClosePurchaseResultDialog: (success: Boolean) -> Unit
+fun Welcome(
+ navigator: DestinationsNavigator,
+ voucherRedeemResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>,
+ playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>
) {
+ val vm = koinViewModel<WelcomeViewModel>()
+ val state by vm.uiState.collectAsState()
+
+ voucherRedeemResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value ->
+ // If we successfully redeemed a voucher, navigate to Connect screen
+ if (it.value) {
+ navigator.navigate(ConnectDestination) {
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
+ }
+ }
+
+ playPaymentResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value)
+ }
+ }
+
val context = LocalContext.current
LaunchedEffect(Unit) {
- uiSideEffect.collect { uiSideEffect ->
+ vm.uiSideEffect.collect { uiSideEffect ->
when (uiSideEffect) {
is WelcomeViewModel.UiSideEffect.OpenAccountView ->
context.openAccountPageInBrowser(uiSideEffect.token)
- WelcomeViewModel.UiSideEffect.OpenConnectScreen -> openConnectScreen()
+ WelcomeViewModel.UiSideEffect.OpenConnectScreen -> {
+ navigator.navigate(ConnectDestination) {
+ launchSingleTop = true
+ popUpTo(NavGraphs.root) { inclusive = true }
+ }
+ }
}
}
}
- var showVerificationPendingDialog by remember { mutableStateOf(false) }
- if (showVerificationPendingDialog) {
- VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
- }
-
- uiState.paymentDialogData?.let {
- PaymentDialog(
- paymentDialogData = uiState.paymentDialogData,
- retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
- onCloseDialog = onClosePurchaseResultDialog
- )
- }
+ WelcomeScreen(
+ uiState = state,
+ onSitePaymentClick = vm::onSitePaymentClick,
+ onRedeemVoucherClick = {
+ navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true }
+ },
+ onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } },
+ onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } },
+ navigateToDeviceInfoDialog = {
+ navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true }
+ },
+ onPurchaseBillingProductClick = { productId ->
+ navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true }
+ },
+ navigateToVerificationPendingDialog = {
+ navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true }
+ }
+ )
+}
+@Composable
+fun WelcomeScreen(
+ uiState: WelcomeUiState,
+ onSitePaymentClick: () -> Unit,
+ onRedeemVoucherClick: () -> Unit,
+ onSettingsClick: () -> Unit,
+ onAccountClick: () -> Unit,
+ onPurchaseBillingProductClick: (productId: ProductId) -> Unit,
+ navigateToDeviceInfoDialog: () -> Unit,
+ navigateToVerificationPendingDialog: () -> Unit
+) {
val scrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() }
@@ -135,13 +184,6 @@ fun WelcomeScreen(
} else {
MaterialTheme.colorScheme.error
},
- statusBarColor =
- if (uiState.tunnelState.isSecured()) {
- MaterialTheme.colorScheme.inversePrimary
- } else {
- MaterialTheme.colorScheme.error
- },
- navigationBarColor = MaterialTheme.colorScheme.background,
iconTintColor =
if (uiState.tunnelState.isSecured()) {
MaterialTheme.colorScheme.onPrimary
@@ -165,18 +207,18 @@ fun WelcomeScreen(
.background(color = MaterialTheme.colorScheme.primary)
) {
// Welcome info area
- WelcomeInfo(snackbarHostState, uiState, showSitePayment)
+ WelcomeInfo(snackbarHostState, uiState, navigateToDeviceInfoDialog)
Spacer(modifier = Modifier.weight(1f))
// Payment button area
PaymentPanel(
- showSitePayment = showSitePayment,
+ showSitePayment = uiState.showSitePayment,
billingPaymentState = uiState.billingPaymentState,
onSitePaymentClick = onSitePaymentClick,
onRedeemVoucherClick = onRedeemVoucherClick,
onPurchaseBillingProductClick = onPurchaseBillingProductClick,
- onPaymentInfoClick = { showVerificationPendingDialog = true }
+ onPaymentInfoClick = navigateToVerificationPendingDialog
)
}
}
@@ -186,7 +228,7 @@ fun WelcomeScreen(
private fun WelcomeInfo(
snackbarHostState: SnackbarHostState,
uiState: WelcomeUiState,
- showSitePayment: Boolean
+ navigateToDeviceInfoDialog: () -> Unit
) {
Column {
Text(
@@ -217,13 +259,13 @@ private fun WelcomeInfo(
AccountNumberRow(snackbarHostState, uiState)
- DeviceNameRow(deviceName = uiState.deviceName)
+ DeviceNameRow(deviceName = uiState.deviceName, navigateToDeviceInfoDialog)
Text(
text =
buildString {
append(stringResource(id = R.string.pay_to_start_using))
- if (showSitePayment) {
+ if (uiState.showSitePayment) {
append(" ")
append(stringResource(id = R.string.add_time_to_account))
}
@@ -269,7 +311,7 @@ private fun AccountNumberRow(snackbarHostState: SnackbarHostState, uiState: Welc
}
@Composable
-fun DeviceNameRow(deviceName: String?) {
+fun DeviceNameRow(deviceName: String?, navigateToDeviceInfoDialog: () -> Unit) {
Row(
modifier = Modifier.padding(horizontal = Dimens.sideMargin),
verticalAlignment = Alignment.CenterVertically,
@@ -288,10 +330,9 @@ fun DeviceNameRow(deviceName: String?) {
color = MaterialTheme.colorScheme.onPrimary
)
- var showDeviceNameDialog by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier.align(Alignment.CenterVertically),
- onClick = { showDeviceNameDialog = true }
+ onClick = navigateToDeviceInfoDialog
) {
Icon(
painter = painterResource(id = R.drawable.icon_info),
@@ -299,9 +340,6 @@ fun DeviceNameRow(deviceName: String?) {
tint = MullvadWhite
)
}
- if (showDeviceNameDialog) {
- DeviceNameInfoDialog { showDeviceNameDialog = false }
- }
}
}
@@ -311,10 +349,9 @@ private fun PaymentPanel(
billingPaymentState: PaymentState?,
onSitePaymentClick: () -> Unit,
onRedeemVoucherClick: () -> Unit,
- onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit,
+ onPurchaseBillingProductClick: (productId: ProductId) -> Unit,
onPaymentInfoClick: () -> Unit
) {
- val context = LocalContext.current
Column(
modifier =
Modifier.fillMaxWidth()
@@ -326,7 +363,7 @@ private fun PaymentPanel(
PlayPayment(
billingPaymentState = billingPaymentState,
onPurchaseBillingProductClick = { productId ->
- onPurchaseBillingProductClick(productId) { context as Activity }
+ onPurchaseBillingProductClick(productId)
},
onInfoClick = onPaymentInfoClick,
modifier =
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt
index e22aaffde2..e539dbafc6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt
@@ -5,13 +5,11 @@ import net.mullvad.mullvadvpn.model.Device
data class DeviceListUiState(
val deviceUiItems: List<DeviceListItemUiState>,
val isLoading: Boolean,
- val stagedDevice: Device?
) {
val hasTooManyDevices = deviceUiItems.count() >= 5
companion object {
- val INITIAL =
- DeviceListUiState(deviceUiItems = emptyList(), isLoading = true, stagedDevice = null)
+ val INITIAL = DeviceListUiState(deviceUiItems = emptyList(), isLoading = true)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
index 0491f80ea0..54fd414f86 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
@@ -1,11 +1,10 @@
package net.mullvad.mullvadvpn.compose.state
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.model.TunnelState
data class OutOfTimeUiState(
val tunnelState: TunnelState = TunnelState.Disconnected,
val deviceName: String = "",
+ val showSitePayment: Boolean = false,
val billingPaymentState: PaymentState? = null,
- val paymentDialogData: PaymentDialogData? = null
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
index e78d2e9f43..5525dee8ce 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
@@ -7,7 +7,6 @@ import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.model.QuantumResistantState
import net.mullvad.mullvadvpn.model.SelectedObfuscation
import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
-import net.mullvad.mullvadvpn.viewmodel.StagedDns
data class VpnSettingsUiState(
val mtu: String,
@@ -16,12 +15,11 @@ data class VpnSettingsUiState(
val isCustomDnsEnabled: Boolean,
val customDnsItems: List<CustomDnsItem>,
val contentBlockersOptions: DefaultDnsOptions,
- val isAllowLanEnabled: Boolean,
val selectedObfuscation: SelectedObfuscation,
val quantumResistant: QuantumResistantState,
val selectedWireguardPort: Constraint<Port>,
+ val customWireguardPort: Constraint<Port>?,
val availablePortRanges: List<PortRange>,
- val dialog: VpnSettingsDialog?
) {
companion object {
@@ -32,12 +30,11 @@ data class VpnSettingsUiState(
isCustomDnsEnabled: Boolean = false,
customDnsItems: List<CustomDnsItem> = emptyList(),
contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(),
- isAllowLanEnabled: Boolean = false,
selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off,
quantumResistant: QuantumResistantState = QuantumResistantState.Off,
selectedWireguardPort: Constraint<Port> = Constraint.Any(),
+ customWireguardPort: Constraint.Only<Port>? = null,
availablePortRanges: List<PortRange> = emptyList(),
- dialog: VpnSettingsDialog? = null
) =
VpnSettingsUiState(
mtu,
@@ -46,36 +43,11 @@ data class VpnSettingsUiState(
isCustomDnsEnabled,
customDnsItems,
contentBlockersOptions,
- isAllowLanEnabled,
selectedObfuscation,
quantumResistant,
selectedWireguardPort,
+ customWireguardPort,
availablePortRanges,
- dialog
)
}
}
-
-interface VpnSettingsDialog {
- data class Mtu(val mtuEditValue: String) : VpnSettingsDialog
-
- data class Dns(val stagedDns: StagedDns) : VpnSettingsDialog
-
- data object LocalNetworkSharingInfo : VpnSettingsDialog
-
- data object ContentBlockersInfo : VpnSettingsDialog
-
- data object CustomDnsInfo : VpnSettingsDialog
-
- data object MalwareInfo : VpnSettingsDialog
-
- data object ObfuscationInfo : VpnSettingsDialog
-
- data object QuantumResistanceInfo : VpnSettingsDialog
-
- data class WireguardPortInfo(val availablePortRanges: List<PortRange> = emptyList()) :
- VpnSettingsDialog
-
- data class CustomPort(val availablePortRanges: List<PortRange> = emptyList()) :
- VpnSettingsDialog
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt
index bd1c19e9c9..e2673a0ddf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt
@@ -1,12 +1,11 @@
package net.mullvad.mullvadvpn.compose.state
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.model.TunnelState
data class WelcomeUiState(
val tunnelState: TunnelState = TunnelState.Disconnected,
val accountNumber: String? = null,
val deviceName: String? = null,
+ val showSitePayment: Boolean = false,
val billingPaymentState: PaymentState? = null,
- val paymentDialogData: PaymentDialogData? = null
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index e3cb4faa5b..ff5bbf43cc 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -1,5 +1,9 @@
package net.mullvad.mullvadvpn.compose.test
+// Top Bar
+const val TOP_BAR_ACCOUNT_BUTTON = "top_bar_account_button"
+const val TOP_BAR_SETTINGS_BUTTON = "top_bar_settings_button"
+
// VpnSettingsScreen
const val LAZY_LIST_TEST_TAG = "lazy_list_test_tag"
const val LAZY_LIST_LAST_ITEM_TEST_TAG = "lazy_list_last_item_test_tag"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt
index d7aec9e417..388bec98bf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt
@@ -9,7 +9,7 @@ fun DnsTextField(
value: String,
modifier: Modifier = Modifier,
onValueChanged: (String) -> Unit = {},
- onSubmit: (String) -> Unit = {},
+ onSubmit: () -> Unit = {},
placeholderText: String?,
isEnabled: Boolean = true,
isValidValue: Boolean = true
@@ -19,7 +19,7 @@ fun DnsTextField(
keyboardType = KeyboardType.Text,
modifier = modifier,
onValueChanged = onValueChanged,
- onSubmit = onSubmit,
+ onSubmit = { onSubmit() },
isEnabled = isEnabled,
placeholderText = placeholderText,
maxCharLength = Int.MAX_VALUE,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt
new file mode 100644
index 0000000000..4c02b278d0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt
@@ -0,0 +1,17 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+
+object DefaultTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = fadeIn()
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = fadeOut()
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = fadeIn()
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt
new file mode 100644
index 0000000000..93c94ecd87
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.LoginDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+
+// This is used for OutOfTime, Welcome, and Connect destinations.
+object HomeTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ when (this.initialState.destination()) {
+ is LoginDestination -> fadeIn()
+ else -> EnterTransition.None
+ }
+
+ // TODO temporary hack until we have a proper solution.
+ // https://issuetracker.google.com/issues/309506799
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ EnterTransition.None
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt
new file mode 100644
index 0000000000..162dacbd90
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination
+import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination
+import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+
+object LoginTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = fadeIn()
+
+ // TODO temporary hack until we have a proper solution.
+ // https://issuetracker.google.com/issues/309506799
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (this.targetState.destination()) {
+ is OutOfTimeDestination,
+ is WelcomeDestination,
+ is ConnectDestination -> fadeOut()
+ else -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = fadeIn()
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt
new file mode 100644
index 0000000000..75fb7286fc
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt
@@ -0,0 +1,36 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor
+
+object SettingsTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInVertically(initialOffsetY = { it })
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() })
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ when (initialState.destination()) {
+ NoDaemonScreenDestination -> fadeIn(snap(0))
+ else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() })
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutVertically(targetOffsetY = { it })
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt
new file mode 100644
index 0000000000..da802483b5
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt
@@ -0,0 +1,58 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor
+
+object SlideInFromBottomTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInVertically(initialOffsetY = { it })
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> fadeOut()
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ when (initialState.destination()) {
+ NoDaemonScreenDestination -> fadeIn(snap(0))
+ else -> fadeIn()
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutVertically(targetOffsetY = { it })
+}
+
+object SelectLocationTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInVertically(initialOffsetY = { it })
+
+ // TODO temporary hack until we have a proper solution.
+ // https://issuetracker.google.com/issues/309506799
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> slideOutHorizontally { -it.withHorizontalScalingFactor() }
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ when (initialState.destination()) {
+ NoDaemonScreenDestination -> fadeIn(snap(0))
+ else -> slideInHorizontally { -it.withHorizontalScalingFactor() }
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutVertically(targetOffsetY = { it })
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt
new file mode 100644
index 0000000000..69baa8eb47
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt
@@ -0,0 +1,34 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor
+
+object SlideInFromRightTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInHorizontally(initialOffsetX = { it })
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() })
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ when (initialState.destination()) {
+ NoDaemonScreenDestination -> fadeIn(snap(0))
+ else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() })
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutHorizontally(targetOffsetX = { it })
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
new file mode 100644
index 0000000000..4ccf15bb63
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
@@ -0,0 +1,11 @@
+package net.mullvad.mullvadvpn.constant
+
+import androidx.compose.animation.core.Spring
+
+const val MINIMUM_LOADING_TIME_MILLIS = 500L
+
+const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt()
+
+const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f
+
+fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt()
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 9e35e67823..e12e3e2322 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
@@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase
import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase
import net.mullvad.mullvadvpn.usecase.PortRangeUseCase
@@ -41,13 +42,18 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
+import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.FilterViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
+import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel
import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
+import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
+import net.mullvad.mullvadvpn.viewmodel.SplashViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel
import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel
@@ -101,6 +107,7 @@ val uiModule = module {
single { NewDeviceNotificationUseCase(get()) }
single { PortRangeUseCase(get()) }
single { RelayListUseCase(get(), get()) }
+ single { OutOfTimeUseCase(get(), get()) }
single { ConnectivityUseCase(get()) }
single { InAppNotificationController(get(), get(), get(), get(), MainScope()) }
@@ -129,20 +136,29 @@ val uiModule = module {
viewModel {
ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG)
}
- viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get()) }
+ viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { DeviceListViewModel(get(), get()) }
viewModel { DeviceRevokedViewModel(get(), get()) }
+ viewModel { MtuDialogViewModel(get()) }
+ viewModel { parameters ->
+ DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull())
+ }
viewModel { LoginViewModel(get(), get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get()) }
viewModel { SelectLocationViewModel(get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) }
+ viewModel { SplashViewModel(get(), get(), get()) }
viewModel { VoucherDialogViewModel(get(), get()) }
- viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) }
- viewModel { WelcomeViewModel(get(), get(), get(), get()) }
+ viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
+ viewModel { WelcomeViewModel(get(), get(), get(), get(), get()) }
viewModel { ReportProblemViewModel(get(), get()) }
viewModel { ViewLogsViewModel(get()) }
- viewModel { OutOfTimeViewModel(get(), get(), get(), get()) }
+ viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get()) }
+ viewModel { PaymentViewModel(get()) }
viewModel { FilterViewModel(get()) }
+
+ // This view model must be single so we correctly attach lifecycle and share it with activity
+ single { NoDaemonViewModel(get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
index d1f395d387..369f3e8fee 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
@@ -4,14 +4,11 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
@@ -29,16 +26,10 @@ class AccountRepository(
private val messageHandler: MessageHandler,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
- private val _cachedCreatedAccount = MutableStateFlow<String?>(null)
- val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow()
-
private val accountCreationEvents: SharedFlow<AccountCreationResult> =
messageHandler
.events<Event.AccountCreationEvent>()
.map { it.result }
- .onEach {
- _cachedCreatedAccount.value = (it as? AccountCreationResult.Success)?.accountToken
- }
.shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed())
val accountExpiryState: StateFlow<AccountExpiry> =
@@ -75,7 +66,6 @@ class AccountRepository(
}
fun logout() {
- clearCreatedAccountCache()
messageHandler.trySendRequest(Request.Logout)
}
@@ -90,8 +80,4 @@ class AccountRepository(
fun clearAccountHistory() {
messageHandler.trySendRequest(Request.ClearAccountHistory)
}
-
- fun clearCreatedAccountCache() {
- _cachedCreatedAccount.value = null
- }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt
index 3086ee9b80..6c5387a5b1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt
@@ -3,17 +3,15 @@ package net.mullvad.mullvadvpn.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import net.mullvad.mullvadvpn.dataproxy.UserReport
class ProblemReportRepository {
private val _problemReport = MutableStateFlow(UserReport("", ""))
val problemReport: StateFlow<UserReport> = _problemReport.asStateFlow()
- fun setEmail(email: String) {
- _problemReport.value = _problemReport.value.copy(email = email)
- }
+ fun setEmail(email: String) = _problemReport.update { it.copy(email = email) }
- fun setDescription(description: String) {
- _problemReport.value = _problemReport.value.copy(description = description)
- }
+ fun setDescription(description: String) =
+ _problemReport.update { it.copy(description = description) }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
index ac9637c683..81c4b85b88 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
@@ -39,16 +39,38 @@ class SettingsRepository(
dnsList: List<InetAddress>,
contentBlockersOptions: DefaultDnsOptions
) {
- serviceConnectionManager
- .customDns()
- ?.setDnsOptions(
- dnsOptions =
- DnsOptions(
- state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default,
- customOptions = CustomDnsOptions(ArrayList(dnsList)),
- defaultOptions = contentBlockersOptions
+ updateDnsSettings {
+ DnsOptions(
+ state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default,
+ customOptions = CustomDnsOptions(ArrayList(dnsList)),
+ defaultOptions = contentBlockersOptions
+ )
+ }
+ }
+
+ fun setDnsState(
+ state: DnsState,
+ ) {
+ updateDnsSettings { it.copy(state = state) }
+ }
+
+ fun updateCustomDnsList(update: (List<InetAddress>) -> List<InetAddress>) {
+ updateDnsSettings { dnsOptions ->
+ val newDnsList = ArrayList(update(dnsOptions.customOptions.addresses.map { it }))
+ dnsOptions.copy(
+ state = if (newDnsList.isEmpty()) DnsState.Default else DnsState.Custom,
+ customOptions =
+ CustomDnsOptions(
+ addresses = newDnsList,
)
)
+ }
+ }
+
+ private fun updateDnsSettings(lambda: (DnsOptions) -> DnsOptions) {
+ settingsUpdates.value?.tunnelOptions?.dnsOptions?.let {
+ serviceConnectionManager.customDns()?.setDnsOptions(lambda(it))
+ }
}
fun setWireguardMtu(value: Int?) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index f5e24dacf1..e0ee6cdd21 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -2,127 +2,69 @@ package net.mullvad.mullvadvpn.ui
import android.Manifest
import android.app.Activity
-import android.app.UiModeManager
import android.content.Intent
-import android.content.pm.ActivityInfo
-import android.content.res.Configuration
import android.net.VpnService
import android.os.Bundle
import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.collectAsState
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.ViewCompositionStrategy
-import androidx.fragment.app.FragmentActivity
-import androidx.fragment.app.FragmentManager
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onSubscription
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeoutOrNull
-import net.mullvad.mullvadvpn.BuildConfig
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog
+import androidx.core.view.WindowCompat
+import net.mullvad.mullvadvpn.compose.screen.MullvadApp
import net.mullvad.mullvadvpn.di.paymentModule
import net.mullvad.mullvadvpn.di.uiModule
import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted
-import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras
import net.mullvad.mullvadvpn.lib.theme.AppTheme
-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
-import net.mullvad.mullvadvpn.ui.fragment.AccountFragment
-import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment
-import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment
-import net.mullvad.mullvadvpn.ui.fragment.FilterFragment
-import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment
-import net.mullvad.mullvadvpn.ui.fragment.LoginFragment
-import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment
-import net.mullvad.mullvadvpn.ui.fragment.PrivacyDisclaimerFragment
-import net.mullvad.mullvadvpn.ui.fragment.SettingsFragment
-import net.mullvad.mullvadvpn.ui.fragment.WelcomeFragment
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS
-import net.mullvad.mullvadvpn.util.addDebounceForUnknownState
-import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
+import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel
import org.koin.android.ext.android.getKoin
import org.koin.core.context.loadKoinModules
-open class MainActivity : FragmentActivity() {
+class MainActivity : ComponentActivity() {
private val requestNotificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// NotificationManager.areNotificationsEnabled is used to check the state rather than
// handling the callback value.
}
- private val deviceIsTv by lazy {
- val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager
-
- uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
- }
-
private lateinit var accountRepository: AccountRepository
private lateinit var deviceRepository: DeviceRepository
private lateinit var privacyDisclaimerRepository: PrivacyDisclaimerRepository
private lateinit var serviceConnectionManager: ServiceConnectionManager
private lateinit var changelogViewModel: ChangelogViewModel
-
- private var deviceStateJob: Job? = null
- private var currentDeviceState: DeviceState? = null
+ private lateinit var serviceConnectionViewModel: NoDaemonViewModel
override fun onCreate(savedInstanceState: Bundle?) {
loadKoinModules(listOf(uiModule, paymentModule))
+ // Tell the system that we will draw behind the status bar and navigation bar
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
getKoin().apply {
accountRepository = get()
deviceRepository = get()
privacyDisclaimerRepository = get()
serviceConnectionManager = get()
changelogViewModel = get()
+ serviceConnectionViewModel = get()
}
-
- requestedOrientation =
- if (deviceIsTv) {
- ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
- } else {
- ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- }
+ lifecycle.addObserver(serviceConnectionViewModel)
super.onCreate(savedInstanceState)
- setContentView(R.layout.main)
- }
-
- override fun onStart() {
- Log.d("mullvad", "Starting main activity")
- super.onStart()
-
- if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) {
- initializeStateHandlerAndServiceConnection(
- apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras()
- )
- } else {
- openPrivacyDisclaimerFragment()
- }
+ setContent { AppTheme { MullvadApp() } }
}
- fun initializeStateHandlerAndServiceConnection(
- apiEndpointConfiguration: ApiEndpointConfiguration?
- ) {
- deviceStateJob = launchDeviceStateHandler()
+ fun initializeStateHandlerAndServiceConnection() {
checkForNotificationPermission()
serviceConnectionManager.bind(
vpnPermissionRequestHandler = ::requestVpnPermission,
- apiEndpointConfiguration = apiEndpointConfiguration
+ apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras()
)
}
@@ -130,6 +72,14 @@ open class MainActivity : FragmentActivity() {
serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK)
}
+ override fun onStart() {
+ super.onStart()
+
+ if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) {
+ initializeStateHandlerAndServiceConnection()
+ }
+ }
+
override fun onStop() {
Log.d("mullvad", "Stopping main activity")
super.onStop()
@@ -137,111 +87,14 @@ open class MainActivity : FragmentActivity() {
// NOTE: `super.onStop()` must be called before unbinding due to the fragment state handling
// otherwise the fragments will believe there was an unexpected disconnect.
serviceConnectionManager.unbind()
-
- deviceStateJob?.cancel()
}
override fun onDestroy() {
serviceConnectionManager.onDestroy()
+ lifecycle.removeObserver(serviceConnectionViewModel)
super.onDestroy()
}
- fun openAccount() {
- supportFragmentManager.beginTransaction().apply {
- setCustomAnimations(
- R.anim.fragment_enter_from_bottom,
- R.anim.do_nothing,
- R.anim.do_nothing,
- R.anim.fragment_exit_to_bottom
- )
- replace(R.id.main_fragment, AccountFragment())
- addToBackStack(null)
- commitAllowingStateLoss()
- }
- }
-
- fun openSettings() {
- supportFragmentManager.beginTransaction().apply {
- setCustomAnimations(
- R.anim.fragment_enter_from_bottom,
- R.anim.do_nothing,
- R.anim.do_nothing,
- R.anim.fragment_exit_to_bottom
- )
- replace(R.id.main_fragment, SettingsFragment())
- addToBackStack(null)
- commitAllowingStateLoss()
- }
- }
-
- fun openFilter() {
- supportFragmentManager.beginTransaction().apply {
- setCustomAnimations(
- R.anim.fragment_enter_from_right,
- R.anim.do_nothing,
- R.anim.do_nothing,
- R.anim.fragment_exit_to_right
- )
- replace(R.id.main_fragment, FilterFragment())
- addToBackStack(null)
- commitAllowingStateLoss()
- }
- }
-
- private fun launchDeviceStateHandler(): Job {
- return lifecycleScope.launch {
- launch {
- deviceRepository.deviceState
- .debounce {
- // Debounce DeviceState.Unknown to delay view transitions during reconnect.
- it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
- }
- .collect { newState ->
- if (newState != currentDeviceState)
- when (newState) {
- is DeviceState.Initial,
- is DeviceState.Unknown -> openLaunchView()
- is DeviceState.LoggedOut -> openLoginView()
- is DeviceState.Revoked -> openRevokedView()
- is DeviceState.LoggedIn -> {
- openLoggedInView(
- accountToken = newState.accountAndDevice.account_token,
- shouldDelayLogin =
- currentDeviceState is DeviceState.LoggedOut
- )
- }
- }
- currentDeviceState = newState
- }
- }
-
- lifecycleScope.launch {
- deviceRepository.deviceState
- .filter { it is DeviceState.LoggedIn || it is DeviceState.LoggedOut }
- .collect { loadChangelogComponent() }
- }
- }
- }
-
- private fun loadChangelogComponent() {
- findViewById<ComposeView>(R.id.compose_view).apply {
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
- setContent {
- val state = changelogViewModel.uiState.collectAsState().value
- if (state is ChangelogDialogUiState.Show) {
- AppTheme {
- ChangelogDialog(
- changesList = state.changes,
- version = BuildConfig.VERSION_NAME,
- onDismiss = { changelogViewModel.dismissChangelogDialog() }
- )
- }
- }
- }
- changelogViewModel.refreshChangelogDialogUiState()
- }
- }
-
@Suppress("DEPRECATION")
private fun requestVpnPermission() {
val intent = VpnService.prepare(this)
@@ -249,97 +102,9 @@ open class MainActivity : FragmentActivity() {
startActivityForResult(intent, 0)
}
- private fun openLaunchView() {
- supportFragmentManager.beginTransaction().apply {
- replace(R.id.main_fragment, LoadingFragment())
- commitAllowingStateLoss()
- }
- }
-
- private fun openPrivacyDisclaimerFragment() {
- supportFragmentManager.beginTransaction().apply {
- replace(R.id.main_fragment, PrivacyDisclaimerFragment())
- commitAllowingStateLoss()
- }
- }
-
- private suspend fun openLoggedInView(accountToken: String, shouldDelayLogin: Boolean) {
- val isNewAccount = accountToken == accountRepository.cachedCreatedAccount.value
- val isExpired = isNewAccount.not() && isExpired(LOGIN_AWAIT_EXPIRY_MILLIS)
-
- val fragment =
- when {
- isNewAccount -> WelcomeFragment()
- isExpired -> {
- if (shouldDelayLogin) {
- delay(LOGIN_DELAY_MILLIS)
- }
- OutOfTimeFragment()
- }
- else -> {
- if (shouldDelayLogin) {
- delay(LOGIN_DELAY_MILLIS)
- }
- ConnectFragment()
- }
- }
-
- supportFragmentManager.beginTransaction().apply {
- replace(R.id.main_fragment, fragment)
- commitAllowingStateLoss()
- }
- }
-
- private suspend fun isExpired(timeoutMillis: Long): Boolean {
- return withTimeoutOrNull(timeoutMillis) {
- accountRepository.accountExpiryState
- .onSubscription { accountRepository.fetchAccountExpiry() }
- .filter { it is AccountExpiry.Available }
- .map { it.date()?.isBeforeNow }
- .first()
- }
- ?: false
- }
-
- private fun openLoginView() {
- clearBackStack()
- supportFragmentManager.beginTransaction().apply {
- replace(R.id.main_fragment, LoginFragment())
- commitAllowingStateLoss()
- }
- }
-
- private fun openRevokedView() {
- clearBackStack()
- supportFragmentManager.beginTransaction().apply {
- setCustomAnimations(
- R.anim.fragment_enter_from_right,
- R.anim.fragment_exit_to_left,
- R.anim.fragment_half_enter_from_left,
- R.anim.fragment_exit_to_right
- )
- replace(R.id.main_fragment, DeviceRevokedFragment())
- commitAllowingStateLoss()
- }
- }
-
- fun clearBackStack() {
- supportFragmentManager.apply {
- if (backStackEntryCount > 0) {
- val firstEntry = getBackStackEntryAt(0)
- popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- }
- }
- }
-
private fun checkForNotificationPermission() {
if (isNotificationPermissionGranted().not()) {
requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
-
- companion object {
- private const val LOGIN_DELAY_MILLIS = 1000L
- private const val LOGIN_AWAIT_EXPIRY_MILLIS = 1000L
- }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
index 7f44b0c7d4..4e1d773f1e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
@@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.ui.serviceconnection
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
import android.os.IBinder
import android.os.Messenger
import android.util.Log
@@ -76,7 +78,15 @@ class ServiceConnectionManager(private val context: Context) : MessageHandler {
}
context.startService(intent)
- context.bindService(intent, serviceConnection, 0)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ context.bindService(
+ intent,
+ serviceConnection,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
+ )
+ } else {
+ context.bindService(intent, serviceConnection, 0)
+ }
isBound = true
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt
new file mode 100644
index 0000000000..fd004562a3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt
@@ -0,0 +1,13 @@
+package net.mullvad.mullvadvpn.util
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+
+fun Context.getActivity(): Activity? {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> this.baseContext.getActivity()
+ else -> null
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
index 9be4b13b59..39cee0342a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
@@ -1,9 +1,6 @@
package net.mullvad.mullvadvpn.util
-import android.view.animation.Animation
-import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -12,31 +9,10 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
-import kotlinx.coroutines.flow.take
import kotlinx.coroutines.withTimeoutOrNull
-import net.mullvad.mullvadvpn.lib.common.util.safeOffer
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.talpid.util.EventNotifier
-fun Animation.transitionFinished(): Flow<Unit> =
- callbackFlow {
- val transitionAnimationListener =
- object : Animation.AnimationListener {
- override fun onAnimationStart(animation: Animation?) {}
-
- override fun onAnimationEnd(animation: Animation?) {
- safeOffer(Unit)
- }
-
- override fun onAnimationRepeat(animation: Animation?) {}
- }
- setAnimationListener(transitionAnimationListener)
- awaitClose {
- Dispatchers.Main.dispatch(EmptyCoroutineContext) { setAnimationListener(null) }
- }
- }
- .take(1)
-
fun <R> Flow<ServiceConnectionState>.flatMapReadyConnectionOrDefault(
default: Flow<R>,
transform: (value: ServiceConnectionState.ConnectedReady) -> Flow<R>
@@ -134,6 +110,13 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
suspend inline fun <T> Deferred<T>.awaitWithTimeoutOrNull(timeout: Long) =
withTimeoutOrNull(timeout) { await() }
+fun <T> Deferred<T>.getOrDefault(default: T) =
+ try {
+ getCompleted()
+ } catch (e: IllegalStateException) {
+ default
+ }
+
@Suppress("UNCHECKED_CAST")
suspend inline fun <T> Flow<T>.retryWithExponentialBackOff(
maxAttempts: Int,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt
index 0a5167da2e..0f0708707e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt
@@ -16,8 +16,8 @@ fun Constraint<Port>.isCustom() =
is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value)
}
-fun Constraint<Port>.toDisplayCustomPort() =
+fun Constraint<Port>.toValueOrNull() =
when (this) {
- is Constraint.Any -> ""
- is Constraint.Only -> this.value.value.toString()
+ is Constraint.Any -> null
+ is Constraint.Only -> this.value.value
}
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 439d0c3c3b..eda8674802 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,15 +3,16 @@ package net.mullvad.mullvadvpn.viewmodel
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.DeviceState
@@ -20,7 +21,6 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.util.toPaymentState
import org.joda.time.DateTime
@@ -30,32 +30,25 @@ class AccountViewModel(
private val paymentUseCase: PaymentUseCase,
deviceRepository: DeviceRepository
) : ViewModel() {
-
- private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
- private val _enterTransitionEndAction = MutableSharedFlow<Unit>()
-
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
val uiState: StateFlow<AccountUiState> =
combine(
deviceRepository.deviceState,
accountRepository.accountExpiryState,
- paymentUseCase.purchaseResult,
paymentUseCase.paymentAvailability
- ) { deviceState, accountExpiry, purchaseResult, paymentAvailability ->
+ ) { deviceState, accountExpiry, paymentAvailability ->
AccountUiState(
deviceName = deviceState.deviceName() ?: "",
accountNumber = deviceState.token() ?: "",
accountExpiry = accountExpiry.date(),
- paymentDialogData = purchaseResult?.toPaymentDialogData(),
+ showSitePayment = IS_PLAY_BUILD.not(),
billingPaymentState = paymentAvailability?.toPaymentState()
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default())
- @Suppress("konsist.ensure public properties use permitted names")
- val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow()
-
init {
updateAccountExpiry()
verifyPurchases()
@@ -64,7 +57,7 @@ class AccountViewModel(
fun onManageAccountClick() {
viewModelScope.launch {
- _uiSideEffect.tryEmit(
+ _uiSideEffect.send(
UiSideEffect.OpenAccountManagementPageInBrowser(
serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
)
@@ -74,10 +67,11 @@ class AccountViewModel(
fun onLogoutClick() {
accountRepository.logout()
+ viewModelScope.launch { _uiSideEffect.send(UiSideEffect.NavigateToLogin) }
}
- fun onTransitionAnimationEnd() {
- viewModelScope.launch { _enterTransitionEndAction.emit(Unit) }
+ fun onCopyAccountNumber(accountNumber: String) {
+ viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) }
}
fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
@@ -116,7 +110,11 @@ class AccountViewModel(
}
sealed class UiSideEffect {
+ data object NavigateToLogin : UiSideEffect()
+
data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect()
+
+ data class CopyAccountNumber(val accountNumber: String) : UiSideEffect()
}
}
@@ -124,8 +122,8 @@ data class AccountUiState(
val deviceName: String?,
val accountNumber: String?,
val accountExpiry: DateTime?,
+ val showSitePayment: Boolean,
val billingPaymentState: PaymentState? = null,
- val paymentDialogData: PaymentDialogData? = null
) {
companion object {
fun default() =
@@ -133,8 +131,8 @@ data class AccountUiState(
deviceName = DeviceState.Unknown.deviceName(),
accountNumber = DeviceState.Unknown.token(),
accountExpiry = AccountExpiry.Missing.date(),
+ showSitePayment = false,
billingPaymentState = PaymentState.Loading,
- paymentDialogData = null,
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt
index f6549cded6..6b17592b8e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt
@@ -1,8 +1,13 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.os.Parcelable
import androidx.lifecycle.ViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.repository.ChangelogRepository
class ChangelogViewModel(
@@ -10,34 +15,26 @@ class ChangelogViewModel(
private val buildVersionCode: Int,
private val alwaysShowChangelog: Boolean
) : ViewModel() {
- private val _uiState = MutableStateFlow<ChangelogDialogUiState>(ChangelogDialogUiState.Hide)
- val uiState = _uiState.asStateFlow()
- fun refreshChangelogDialogUiState() {
- val shouldShowChangelogDialog =
- alwaysShowChangelog ||
- changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersionCode
- _uiState.value =
- if (shouldShowChangelogDialog) {
- val changelogList = changelogRepository.getLastVersionChanges()
- if (changelogList.isNotEmpty()) {
- ChangelogDialogUiState.Show(changelogList)
- } else {
- ChangelogDialogUiState.Hide
- }
- } else {
- ChangelogDialogUiState.Hide
- }
+ private val _uiSideEffect = MutableSharedFlow<Changelog>(replay = 1, extraBufferCapacity = 1)
+ val uiSideEffect: SharedFlow<Changelog> = _uiSideEffect
+
+ init {
+ if (shouldShowChangelog()) {
+ val changelog =
+ Changelog(BuildConfig.VERSION_NAME, changelogRepository.getLastVersionChanges())
+ viewModelScope.launch { _uiSideEffect.emit(changelog) }
+ }
}
- fun dismissChangelogDialog() {
+ fun markChangelogAsRead() {
changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersionCode)
- _uiState.value = ChangelogDialogUiState.Hide
}
-}
-sealed class ChangelogDialogUiState {
- data class Show(val changes: List<String>) : ChangelogDialogUiState()
-
- data object Hide : ChangelogDialogUiState()
+ private fun shouldShowChangelog(): Boolean =
+ alwaysShowChangelog ||
+ (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersionCode &&
+ changelogRepository.getLastVersionChanges().isNotEmpty())
}
+
+@Parcelize data class Changelog(val version: String, val changes: List<String>) : Parcelable
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 76c290f439..976eed1270 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,20 +3,22 @@ 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.channels.awaitClose
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -33,6 +35,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
@@ -41,7 +44,6 @@ import net.mullvad.mullvadvpn.util.daysFromNow
import net.mullvad.mullvadvpn.util.toInAddress
import net.mullvad.mullvadvpn.util.toOutAddress
import net.mullvad.talpid.tunnel.ActionAfterDisconnect
-import net.mullvad.talpid.tunnel.ErrorStateCause
@OptIn(FlowPreview::class)
class ConnectViewModel(
@@ -51,10 +53,11 @@ class ConnectViewModel(
private val inAppNotificationController: InAppNotificationController,
private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase,
private val relayListUseCase: RelayListUseCase,
+ private val outOfTimeUseCase: OutOfTimeUseCase,
private val paymentUseCase: PaymentUseCase
) : ViewModel() {
- private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
private val _shared: SharedFlow<ServiceConnectionContainer> =
serviceConnectionManager.connectionState
@@ -90,9 +93,6 @@ class ConnectViewModel(
accountExpiry,
isTunnelInfoExpanded,
deviceName ->
- if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) {
- _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView)
- }
ConnectUiState(
location =
when (tunnelRealState) {
@@ -136,9 +136,12 @@ class ConnectViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL)
init {
- // The create account cache is no longer needed as we have successfully reached the connect
- // screen
- accountRepository.clearCreatedAccountCache()
+ viewModelScope.launch {
+ // This once we get isOutOfTime true we will navigate to OutOfTime view.
+ outOfTimeUseCase.isOutOfTime().first { it == true }
+ _uiSideEffect.send(UiSideEffect.OutOfTime)
+ }
+
viewModelScope.launch {
paymentUseCase.verifyPurchases { accountRepository.fetchAccountExpiry() }
}
@@ -155,12 +158,6 @@ class ConnectViewModel(
private fun ConnectionProxy.tunnelRealStateFlow(): Flow<TunnelState> =
callbackFlowFromNotifier(this.onStateChange)
- private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean {
- return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed)
- ?.isCausedByExpiredAccount()
- ?: false
- }
-
fun toggleTunnelInfoExpansion() {
_isTunnelInfoExpanded.value = _isTunnelInfoExpanded.value.not()
}
@@ -183,7 +180,7 @@ class ConnectViewModel(
fun onManageAccountClick() {
viewModelScope.launch {
- _uiSideEffect.tryEmit(
+ _uiSideEffect.send(
UiSideEffect.OpenAccountManagementPageInBrowser(
serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
)
@@ -198,7 +195,7 @@ class ConnectViewModel(
sealed interface UiSideEffect {
data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect
- data object OpenOutOfTimeView : UiSideEffect
+ data object OutOfTime : UiSideEffect
}
companion object {
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 98648e0015..48a8782d04 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,14 +5,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onSubscription
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -33,22 +34,15 @@ class DeviceListViewModel(
private val resources: Resources,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() {
- private val _stagedDeviceId = MutableStateFlow<DeviceId?>(null)
private val _loadingDevices = MutableStateFlow<List<DeviceId>>(emptyList())
- private val _toastMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
- @Suppress("konsist.ensure public properties use permitted names")
- val toastMessages = _toastMessages.asSharedFlow()
+ private val _uiSideEffect = Channel<DeviceListSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
- @Suppress("konsist.ensure public properties use permitted names")
- var accountToken: String? = null
private var cachedDeviceList: List<Device>? = null
val uiState =
- combine(deviceRepository.deviceList, _stagedDeviceId, _loadingDevices) {
- deviceList,
- stagedDeviceId,
- loadingDevices ->
+ combine(deviceRepository.deviceList, _loadingDevices) { deviceList, loadingDevices ->
val devices =
if (deviceList is DeviceList.Available) {
deviceList.devices.also { cachedDeviceList = it }
@@ -65,66 +59,47 @@ class DeviceListViewModel(
)
}
val isLoading = devices == null
- val stagedDevice = devices?.firstOrNull { device -> device.id == stagedDeviceId }
DeviceListUiState(
deviceUiItems = deviceUiItems ?: emptyList(),
isLoading = isLoading,
- stagedDevice = stagedDevice
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL)
- fun stageDeviceForRemoval(deviceId: DeviceId) {
- _stagedDeviceId.value = deviceId
- }
-
- fun clearStagedDevice() {
- _stagedDeviceId.value = null
- }
+ fun removeDevice(accountToken: String, deviceIdToRemove: DeviceId) {
- fun confirmRemovalOfStagedDevice() {
- val token = accountToken
- val stagedDeviceId = _stagedDeviceId.value
-
- if (token != null && stagedDeviceId != null) {
- viewModelScope.launch {
- withContext(dispatcher) {
- val result =
- withTimeoutOrNull(DEVICE_REMOVAL_TIMEOUT_MILLIS) {
- deviceRepository.deviceRemovalEvent
- .onSubscription {
- clearStagedDevice()
- setLoadingDevice(stagedDeviceId)
- deviceRepository.removeDevice(token, stagedDeviceId)
- }
- .filter { (deviceId, result) ->
- deviceId == stagedDeviceId && result == RemoveDeviceResult.Ok
- }
- .first()
- }
+ viewModelScope.launch {
+ withContext(dispatcher) {
+ val result =
+ withTimeoutOrNull(DEVICE_REMOVAL_TIMEOUT_MILLIS) {
+ deviceRepository.deviceRemovalEvent
+ .onSubscription {
+ setLoadingDevice(deviceIdToRemove)
+ deviceRepository.removeDevice(accountToken, deviceIdToRemove)
+ }
+ .filter { (deviceId, result) ->
+ deviceId == deviceIdToRemove && result == RemoveDeviceResult.Ok
+ }
+ .first()
+ }
- clearLoadingDevice(stagedDeviceId)
+ clearLoadingDevice(deviceIdToRemove)
- if (result == null) {
- _toastMessages.tryEmit(
+ if (result == null) {
+ _uiSideEffect.send(
+ DeviceListSideEffect.ShowToast(
resources.getString(R.string.failed_to_remove_device)
)
- refreshDeviceList()
- }
+ )
+ refreshDeviceList(accountToken)
}
}
- } else {
- _toastMessages.tryEmit(resources.getString(R.string.error_occurred))
- clearLoadingDevices()
- clearStagedDevice()
- refreshDeviceList()
}
}
fun refreshDeviceState() = deviceRepository.refreshDeviceState()
- fun refreshDeviceList() =
- accountToken?.let { token -> deviceRepository.refreshDeviceList(token) }
+ fun refreshDeviceList(accountToken: String) = deviceRepository.refreshDeviceList(accountToken)
private fun setLoadingDevice(deviceId: DeviceId) {
_loadingDevices.value = _loadingDevices.value.toMutableList().apply { add(deviceId) }
@@ -134,11 +109,11 @@ class DeviceListViewModel(
_loadingDevices.value = _loadingDevices.value.toMutableList().apply { remove(deviceId) }
}
- private fun clearLoadingDevices() {
- _loadingDevices.value = emptyList()
- }
-
companion object {
private const val DEVICE_REMOVAL_TIMEOUT_MILLIS = 5000L
}
}
+
+sealed interface DeviceListSideEffect {
+ data class ShowToast(val text: String) : DeviceListSideEffect
+}
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
new file mode 100644
index 0000000000..b931d4a7ba
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt
@@ -0,0 +1,171 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+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
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.constant.EMPTY_STRING
+import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import org.apache.commons.validator.routines.InetAddressValidator
+
+sealed interface DnsDialogSideEffect {
+ data object Complete : DnsDialogSideEffect
+}
+
+data class DnsDialogViewModelState(
+ val customDnsList: List<InetAddress>,
+ val isAllowLanEnabled: Boolean
+) {
+ companion object {
+ fun default() = DnsDialogViewModelState(emptyList(), false)
+ }
+}
+
+data class DnsDialogViewState(
+ val ipAddress: String,
+ val validationResult: ValidationResult = ValidationResult.Success,
+ val isLocal: Boolean,
+ val isAllowLanEnabled: Boolean,
+ val isNewEntry: Boolean
+) {
+
+ fun isValid() = (validationResult is ValidationResult.Success)
+
+ sealed class ValidationResult {
+ data object Success : ValidationResult()
+
+ data object InvalidAddress : ValidationResult()
+
+ data object DuplicateAddress : ValidationResult()
+ }
+}
+
+class DnsDialogViewModel(
+ private val repository: SettingsRepository,
+ private val inetAddressValidator: InetAddressValidator,
+ private val index: Int? = null,
+ initialValue: String?,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : ViewModel() {
+
+ private val _ipAddressInput = MutableStateFlow(initialValue ?: EMPTY_STRING)
+
+ private val vmState =
+ repository.settingsUpdates
+ .filterNotNull()
+ .map {
+ val customDnsList = it.addresses()
+ val isAllowLanEnabled = it.allowLan
+ DnsDialogViewModelState(customDnsList, isAllowLanEnabled = isAllowLanEnabled)
+ }
+ .stateIn(viewModelScope, SharingStarted.Lazily, DnsDialogViewModelState.default())
+
+ val uiState: StateFlow<DnsDialogViewState> =
+ combine(_ipAddressInput, vmState, ::createViewState)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.Lazily,
+ createViewState(_ipAddressInput.value, vmState.value)
+ )
+
+ private val _uiSideEffect = Channel<DnsDialogSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ private fun createViewState(ipAddress: String, vmState: DnsDialogViewModelState) =
+ DnsDialogViewState(
+ ipAddress,
+ ipAddress.validateDnsEntry(index, vmState.customDnsList),
+ ipAddress.isLocalAddress(),
+ isAllowLanEnabled = vmState.isAllowLanEnabled,
+ index == null
+ )
+
+ private fun String.validateDnsEntry(
+ index: Int?,
+ dnsList: List<InetAddress>
+ ): DnsDialogViewState.ValidationResult =
+ when {
+ this.isBlank() || !this.isValidIp() -> {
+ DnsDialogViewState.ValidationResult.InvalidAddress
+ }
+ InetAddress.getByName(this).isDuplicateDnsEntry(index, dnsList) -> {
+ DnsDialogViewState.ValidationResult.DuplicateAddress
+ }
+ else -> DnsDialogViewState.ValidationResult.Success
+ }
+
+ fun onDnsInputChange(ipAddress: String) {
+ _ipAddressInput.value = ipAddress
+ }
+
+ fun onSaveDnsClick() =
+ viewModelScope.launch(dispatcher) {
+ if (!uiState.value.isValid()) return@launch
+
+ val address = InetAddress.getByName(uiState.value.ipAddress)
+
+ repository.updateCustomDnsList {
+ it.toMutableList().apply {
+ if (index != null) {
+ set(index, address)
+ } else {
+ add(address)
+ }
+ }
+ }
+
+ _uiSideEffect.send(DnsDialogSideEffect.Complete)
+ }
+
+ fun onRemoveDnsClick() =
+ viewModelScope.launch(dispatcher) {
+ repository.updateCustomDnsList {
+ it.filter { it.hostAddress != uiState.value.ipAddress }
+ }
+ _uiSideEffect.send(DnsDialogSideEffect.Complete)
+ }
+
+ private fun String.isValidIp(): Boolean {
+ return inetAddressValidator.isValid(this)
+ }
+
+ private fun String.isLocalAddress(): Boolean {
+ return isValidIp() && InetAddress.getByName(this).isLocalAddress()
+ }
+
+ private fun InetAddress.isLocalAddress(): Boolean {
+ return isLinkLocalAddress || isSiteLocalAddress
+ }
+
+ private fun InetAddress.isDuplicateDnsEntry(
+ currentIndex: Int? = null,
+ dnsList: List<InetAddress>
+ ): Boolean =
+ dnsList.withIndex().any { (index, entry) ->
+ if (index == currentIndex) {
+ // Ignore current index, it may be the same
+ false
+ } else {
+ entry == this
+ }
+ }
+
+ private fun Settings.addresses() = tunnelOptions.dnsOptions.customOptions.addresses
+
+ companion object {
+ private const val EMPTY_STRING = ""
+ }
+}
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 9178a22110..3f95d79193 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,13 +2,14 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.RelayFilterState
@@ -23,8 +24,8 @@ import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase
class FilterViewModel(
private val relayListFilterUseCase: RelayListFilterUseCase,
) : ViewModel() {
- private val _uiSideEffect = MutableSharedFlow<Unit>()
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<FilterScreenSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
private val selectedOwnership = MutableStateFlow<Ownership?>(null)
private val selectedProviders = MutableStateFlow<List<Provider>>(emptyList())
@@ -101,7 +102,11 @@ class FilterViewModel(
newSelectedOwnership,
newSelectedProviders
)
- _uiSideEffect.emit(Unit)
+ _uiSideEffect.send(FilterScreenSideEffect.CloseScreen)
}
}
}
+
+sealed interface FilterScreenSideEffect {
+ data object CloseScreen : FilterScreenSideEffect
+}
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 34648f1d53..2de5d42a05 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,13 +5,17 @@ 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -23,6 +27,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
import net.mullvad.mullvadvpn.constant.LOGIN_TIMEOUT_MILLIS
import net.mullvad.mullvadvpn.model.AccountCreationResult
+import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.AccountToken
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.repository.AccountRepository
@@ -30,6 +35,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
import net.mullvad.mullvadvpn.util.awaitWithTimeoutOrNull
+import net.mullvad.mullvadvpn.util.getOrDefault
private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L
@@ -38,6 +44,8 @@ sealed interface LoginUiSideEffect {
data object NavigateToConnect : LoginUiSideEffect
+ data object NavigateToOutOfTime : LoginUiSideEffect
+
data class TooManyDevices(val accountToken: AccountToken) : LoginUiSideEffect
}
@@ -51,8 +59,8 @@ class LoginViewModel(
private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState)
private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput)
- private val _uiSideEffect = MutableSharedFlow<LoginUiSideEffect>(extraBufferCapacity = 1)
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<LoginUiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
private val _uiState =
combine(
@@ -95,8 +103,19 @@ class LoginViewModel(
when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) {
LoginResult.Ok -> {
launch {
+ val isOutOfTimeDeferred = async {
+ accountRepository.accountExpiryState
+ .filterIsInstance<AccountExpiry.Available>()
+ .map { it.expiryDateTime.isBeforeNow }
+ .first()
+ }
delay(1000)
- _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect)
+ val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false)
+ if (isOutOfTime) {
+ _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime)
+ } else {
+ _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect)
+ }
}
newDeviceNotificationUseCase.newDeviceCreated()
Success
@@ -114,10 +133,11 @@ class LoginViewModel(
if (refreshResult.isAvailable()) {
// Navigate to device list
- _uiSideEffect.emit(
+
+ _uiSideEffect.send(
LoginUiSideEffect.TooManyDevices(AccountToken(accountToken))
)
- return@launch
+ Idle()
} else {
// Failed to fetch devices list
Idle(LoginError.Unknown(result.toString()))
@@ -137,7 +157,7 @@ class LoginViewModel(
private suspend fun AccountCreationResult.mapToUiState(): LoginState? {
return if (this is AccountCreationResult.Success) {
- _uiSideEffect.emit(LoginUiSideEffect.NavigateToWelcome)
+ _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome)
null
} else {
Idle(LoginError.UnableToCreateAccount)
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
new file mode 100644
index 0000000000..db324e0b13
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt
@@ -0,0 +1,39 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+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
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.util.isValidMtu
+
+class MtuDialogViewModel(
+ private val repository: SettingsRepository,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : ViewModel() {
+
+ private val _uiSideEffect = Channel<MtuDialogSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun onSaveClick(mtuValue: Int) =
+ viewModelScope.launch(dispatcher) {
+ if (mtuValue.isValidMtu()) {
+ repository.setWireguardMtu(mtuValue)
+ }
+ _uiSideEffect.send(MtuDialogSideEffect.Complete)
+ }
+
+ fun onRestoreClick() =
+ viewModelScope.launch(dispatcher) {
+ repository.setWireguardMtu(null)
+ _uiSideEffect.send(MtuDialogSideEffect.Complete)
+ }
+}
+
+sealed interface MtuDialogSideEffect {
+ data object Complete : MtuDialogSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt
new file mode 100644
index 0000000000..eff31be0ee
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt
@@ -0,0 +1,119 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.os.Bundle
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import com.ramcosta.composedestinations.spec.DestinationSpec
+import com.ramcosta.composedestinations.utils.destination
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination
+import net.mullvad.mullvadvpn.compose.destinations.SplashDestination
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+
+private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination)
+
+class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) :
+ ViewModel(), LifecycleEventObserver, NavController.OnDestinationChangedListener {
+
+ private val lifecycleFlow: MutableSharedFlow<Lifecycle.Event> = MutableSharedFlow()
+ private val destinationFlow: MutableSharedFlow<DestinationSpec<*>> = MutableSharedFlow()
+
+ @OptIn(FlowPreview::class)
+ val uiSideEffect =
+ combine(lifecycleFlow, serviceConnectionManager.connectionState, destinationFlow) {
+ event,
+ connEvent,
+ destination ->
+ toDaemonState(event, connEvent, destination)
+ }
+ .map { state ->
+ when (state) {
+ is DaemonState.Show -> DaemonScreenEvent.Show
+ is DaemonState.Hidden.Ignored -> DaemonScreenEvent.Remove
+ DaemonState.Hidden.Connected -> DaemonScreenEvent.Remove
+ }
+ }
+ .distinctUntilChanged()
+ // We debounce any disconnected state to let the UI have some time to connect after a
+ // onStart/onStop event.
+ .debounce {
+ when (it) {
+ is DaemonScreenEvent.Remove -> 0.seconds
+ is DaemonScreenEvent.Show -> SERVICE_DISCONNECT_DEBOUNCE
+ }
+ }
+ .distinctUntilChanged()
+ .shareIn(viewModelScope, SharingStarted.Eagerly)
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ viewModelScope.launch { lifecycleFlow.emit(event) }
+ }
+
+ private fun toDaemonState(
+ lifecycleEvent: Lifecycle.Event,
+ serviceState: ServiceConnectionState,
+ currentDestination: DestinationSpec<*>
+ ): DaemonState {
+ // In these destinations we don't care about showing the NoDaemonScreen
+ if (currentDestination in noServiceDestinations) {
+ return DaemonState.Hidden.Ignored
+ }
+
+ return if (lifecycleEvent.targetState.isAtLeast(Lifecycle.State.STARTED)) {
+ // If we are started we want to show the overlay if we are not connected to daemon
+ when (serviceState) {
+ is ServiceConnectionState.ConnectedNotReady,
+ ServiceConnectionState.Disconnected -> DaemonState.Show
+ is ServiceConnectionState.ConnectedReady -> DaemonState.Hidden.Connected
+ }
+ } else {
+ // If we are stopped we intentionally stop service and don't care about showing overlay.
+ DaemonState.Hidden.Ignored
+ }
+ }
+
+ override fun onDestinationChanged(
+ controller: NavController,
+ destination: NavDestination,
+ arguments: Bundle?
+ ) {
+ viewModelScope.launch {
+ controller.currentBackStackEntry?.destination()?.let { destinationFlow.emit(it) }
+ }
+ }
+
+ companion object {
+ private val SERVICE_DISCONNECT_DEBOUNCE = 2.seconds
+ }
+}
+
+sealed interface DaemonState {
+ data object Show : DaemonState
+
+ sealed interface Hidden : DaemonState {
+ data object Ignored : Hidden
+
+ data object Connected : Hidden
+ }
+}
+
+sealed interface DaemonScreenEvent {
+ data object Show : DaemonScreenEvent
+
+ data object Remove : DaemonScreenEvent
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt
index 001469c26b..8c9a39bbdc 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
@@ -1,23 +1,23 @@
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.delay
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
-import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
@@ -26,22 +26,22 @@ 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.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.util.toPaymentState
-import org.joda.time.DateTime
class OutOfTimeViewModel(
private val accountRepository: AccountRepository,
private val serviceConnectionManager: ServiceConnectionManager,
private val deviceRepository: DeviceRepository,
private val paymentUseCase: PaymentUseCase,
+ private val outOfTimeUseCase: OutOfTimeUseCase,
private val pollAccountExpiry: Boolean = true,
) : ViewModel() {
- private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
val uiState =
serviceConnectionManager.connectionState
@@ -57,13 +57,12 @@ class OutOfTimeViewModel(
serviceConnection.connectionProxy.tunnelStateFlow(),
deviceRepository.deviceState,
paymentUseCase.paymentAvailability,
- paymentUseCase.purchaseResult
- ) { tunnelState, deviceState, paymentAvailability, purchaseResult ->
+ ) { tunnelState, deviceState, paymentAvailability ->
OutOfTimeUiState(
tunnelState = tunnelState,
deviceName = deviceState.deviceName() ?: "",
+ showSitePayment = IS_PLAY_BUILD.not(),
billingPaymentState = paymentAvailability?.toPaymentState(),
- paymentDialogData = purchaseResult?.toPaymentDialogData()
)
}
}
@@ -71,18 +70,11 @@ class OutOfTimeViewModel(
init {
viewModelScope.launch {
- accountRepository.accountExpiryState.collectLatest { accountExpiry ->
- accountExpiry.date()?.let { expiry ->
- val tomorrow = DateTime.now().plusHours(20)
-
- if (expiry.isAfter(tomorrow)) {
- // Reset purchase state
- paymentUseCase.resetPurchaseResult()
- _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
- }
- }
- }
+ outOfTimeUseCase.isOutOfTime().first { it == false }
+ paymentUseCase.resetPurchaseResult()
+ _uiSideEffect.send(UiSideEffect.OpenConnectScreen)
}
+
viewModelScope.launch {
while (pollAccountExpiry) {
updateAccountExpiry()
@@ -98,7 +90,7 @@ class OutOfTimeViewModel(
fun onSitePaymentClick() {
viewModelScope.launch {
- _uiSideEffect.tryEmit(
+ _uiSideEffect.send(
UiSideEffect.OpenAccountView(
serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
)
@@ -110,10 +102,6 @@ class OutOfTimeViewModel(
viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() }
}
- fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
- viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
- }
-
private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
@@ -132,7 +120,7 @@ class OutOfTimeViewModel(
// should check payment availability and verify any purchases to handle potential errors.
if (success) {
updateAccountExpiry()
- _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
+ // _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
} else {
fetchPaymentAvailability()
verifyPurchases() // Attempt to verify again
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt
new file mode 100644
index 0000000000..7f210721df
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt
@@ -0,0 +1,46 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.app.Activity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+
+class PaymentViewModel(
+ private val paymentUseCase: PaymentUseCase,
+) : ViewModel() {
+ val uiState: StateFlow<PaymentUiState> =
+ paymentUseCase.purchaseResult
+ .filterNot {
+ it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError
+ }
+ .map { PaymentUiState(it?.toPaymentDialogData()) }
+ .stateIn(viewModelScope, SharingStarted.Lazily, PaymentUiState(PaymentDialogData()))
+
+ val uiSideEffect =
+ paymentUseCase.purchaseResult
+ .filter {
+ it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError
+ }
+ .map { PaymentUiSideEffect.PaymentCancelled }
+
+ fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
+ viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
+ }
+}
+
+data class PaymentUiState(val paymentDialogData: PaymentDialogData?)
+
+sealed interface PaymentUiSideEffect {
+ data object PaymentCancelled : PaymentUiSideEffect
+}
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 c3b63bb818..f8e6b13f3d 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
@@ -1,10 +1,27 @@
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.receiveAsFlow
+import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
class PrivacyDisclaimerViewModel(
private val privacyDisclaimerRepository: PrivacyDisclaimerRepository
) : ViewModel() {
- fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted()
+
+ private val _uiSideEffect =
+ Channel<PrivacyDisclaimerUiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun setPrivacyDisclosureAccepted() {
+ privacyDisclaimerRepository.setPrivacyDisclosureAccepted()
+ viewModelScope.launch { _uiSideEffect.send(PrivacyDisclaimerUiSideEffect.NavigateToLogin) }
+ }
+}
+
+sealed interface PrivacyDisclaimerUiSideEffect {
+ data object NavigateToLogin : PrivacyDisclaimerUiSideEffect
}
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 82e66b0c4b..52311f82a0 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,10 +3,13 @@ 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
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.constant.MINIMUM_LOADING_TIME_MILLIS
@@ -16,7 +19,6 @@ import net.mullvad.mullvadvpn.dataproxy.UserReport
import net.mullvad.mullvadvpn.repository.ProblemReportRepository
data class ReportProblemUiState(
- val showConfirmNoEmail: Boolean = false,
val sendingState: SendingReportUiState? = null,
val email: String = "",
val description: String = "",
@@ -30,22 +32,23 @@ sealed interface SendingReportUiState {
data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState
}
+sealed interface ReportProblemSideEffect {
+ data object ShowConfirmNoEmail : ReportProblemSideEffect
+}
+
class ReportProblemViewModel(
private val mullvadProblemReporter: MullvadProblemReport,
private val problemReportRepository: ProblemReportRepository
) : ViewModel() {
- private val showConfirmNoEmail = MutableStateFlow(false)
private val sendingState: MutableStateFlow<SendingReportUiState?> = MutableStateFlow(null)
val uiState =
combine(
- showConfirmNoEmail,
sendingState,
problemReportRepository.problemReport,
- ) { showConfirmNoEmail, pendingState, userReport ->
+ ) { pendingState, userReport ->
ReportProblemUiState(
- showConfirmNoEmail = showConfirmNoEmail,
sendingState = pendingState,
email = userReport.email ?: "",
description = userReport.description,
@@ -53,18 +56,17 @@ class ReportProblemViewModel(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReportProblemUiState())
- fun sendReport(
- email: String,
- description: String,
- ) {
+ private val _uiSideEffect = Channel<ReportProblemSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun sendReport(email: String, description: String, skipEmptyEmailCheck: Boolean = false) {
viewModelScope.launch {
val userEmail = email.trim()
val nullableEmail = if (email.isEmpty()) null else userEmail
- if (shouldShowConfirmNoEmail(nullableEmail)) {
- showConfirmNoEmail.tryEmit(true)
+ if (!skipEmptyEmailCheck && shouldShowConfirmNoEmail(nullableEmail)) {
+ _uiSideEffect.send(ReportProblemSideEffect.ShowConfirmNoEmail)
} else {
- sendingState.tryEmit(SendingReportUiState.Sending)
- showConfirmNoEmail.tryEmit(false)
+ sendingState.emit(SendingReportUiState.Sending)
// Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS
val deferredResult = async {
@@ -87,10 +89,6 @@ class ReportProblemViewModel(
sendingState.tryEmit(null)
}
- fun dismissConfirmNoEmail() {
- showConfirmNoEmail.tryEmit(false)
- }
-
fun updateEmail(email: String) {
problemReportRepository.setEmail(email)
}
@@ -100,9 +98,7 @@ class ReportProblemViewModel(
}
private fun shouldShowConfirmNoEmail(userEmail: String?): Boolean =
- userEmail.isNullOrEmpty() &&
- !uiState.value.showConfirmNoEmail &&
- uiState.value.sendingState !is SendingReportUiState
+ userEmail.isNullOrEmpty() && uiState.value.sendingState !is SendingReportUiState
private fun SendProblemReportResult.toUiResult(email: String?): SendingReportUiState =
when (this) {
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 caddae313b..dc9d5e7d6f 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,12 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
@@ -28,9 +29,6 @@ class SelectLocationViewModel(
private val relayListUseCase: RelayListUseCase,
private val relayListFilterUseCase: RelayListFilterUseCase
) : ViewModel() {
-
- private val _closeAction = MutableSharedFlow<Unit>()
- private val _enterTransitionEndAction = MutableSharedFlow<Unit>()
private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
val uiState =
@@ -83,20 +81,13 @@ class SelectLocationViewModel(
SelectLocationUiState.Loading
)
- @Suppress("konsist.ensure public properties use permitted names")
- val uiCloseAction = _closeAction.asSharedFlow()
-
- @Suppress("konsist.ensure public properties use permitted names")
- val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow()
+ private val _uiSideEffect = Channel<SelectLocationSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
fun selectRelay(relayItem: RelayItem) {
relayListUseCase.updateSelectedRelayLocation(relayItem.location)
serviceConnectionManager.connectionProxy()?.connect()
- viewModelScope.launch { _closeAction.emit(Unit) }
- }
-
- fun onTransitionAnimationEnd() {
- viewModelScope.launch { _enterTransitionEndAction.emit(Unit) }
+ viewModelScope.launch { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) }
}
fun onSearchTermInput(searchTerm: String) {
@@ -147,3 +138,7 @@ class SelectLocationViewModel(
private const val EMPTY_SEARCH_TERM = ""
}
}
+
+sealed interface SelectLocationSideEffect {
+ data object CloseScreen : SelectLocationSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
index fb357dfe2a..8ef85cfca8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
@@ -2,13 +2,10 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.SettingsUiState
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.repository.DeviceRepository
@@ -18,7 +15,6 @@ class SettingsViewModel(
deviceRepository: DeviceRepository,
serviceConnectionManager: ServiceConnectionManager
) : ViewModel() {
- private val _enterTransitionEndAction = MutableSharedFlow<Unit>()
private val vmState: StateFlow<SettingsUiState> =
combine(deviceRepository.deviceState, serviceConnectionManager.connectionState) {
@@ -44,11 +40,4 @@ class SettingsViewModel(
SharingStarted.WhileSubscribed(),
SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = false)
)
-
- @Suppress("konsist.ensure public properties use permitted names")
- val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow()
-
- fun onTransitionAnimationEnd() {
- viewModelScope.launch { _enterTransitionEndAction.emit(Unit) }
- }
}
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
new file mode 100644
index 0000000000..8163fb9770
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt
@@ -0,0 +1,110 @@
+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.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.selects.onTimeout
+import kotlinx.coroutines.selects.select
+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.DeviceRepository
+import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
+
+class SplashViewModel(
+ private val privacyDisclaimerRepository: PrivacyDisclaimerRepository,
+ private val deviceRepository: DeviceRepository,
+ private val messageHandler: MessageHandler,
+) : ViewModel() {
+
+ private val _uiSideEffect = Channel<SplashUiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun start() {
+ viewModelScope.launch {
+ if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) {
+ _uiSideEffect.send(getStartDestination())
+ } else {
+ _uiSideEffect.send(SplashUiSideEffect.NavigateToPrivacyDisclaimer)
+ }
+ }
+ }
+
+ private suspend fun getStartDestination(): SplashUiSideEffect {
+ val deviceState =
+ deviceRepository.deviceState
+ .map {
+ when (it) {
+ DeviceState.Initial -> null
+ is DeviceState.LoggedIn ->
+ ValidStartDeviceState.LoggedIn(it.accountAndDevice)
+ DeviceState.LoggedOut -> ValidStartDeviceState.LoggedOut
+ DeviceState.Revoked -> ValidStartDeviceState.Revoked
+ DeviceState.Unknown -> null
+ }
+ }
+ .filterNotNull()
+ .first()
+
+ return when (deviceState) {
+ ValidStartDeviceState.LoggedOut -> SplashUiSideEffect.NavigateToLogin
+ ValidStartDeviceState.Revoked -> SplashUiSideEffect.NavigateToRevoked
+ is ValidStartDeviceState.LoggedIn -> getLoggedInStartDestination()
+ }
+ }
+
+ // We know the user is logged in, but we need to find out if their account has expired
+ private suspend fun getLoggedInStartDestination(): SplashUiSideEffect {
+ val expiry =
+ viewModelScope.async {
+ messageHandler.events<Event.AccountExpiryEvent>().map { it.expiry }.first()
+ }
+
+ val accountExpiry = select {
+ expiry.onAwait { it }
+ // If we don't get a response within 1 second, assume the account expiry is Missing
+ onTimeout(1000) { AccountExpiry.Missing }
+ }
+
+ return when (accountExpiry) {
+ is AccountExpiry.Available -> {
+ if (accountExpiry.expiryDateTime.isBeforeNow) {
+ SplashUiSideEffect.NavigateToOutOfTime
+ } else {
+ SplashUiSideEffect.NavigateToConnect
+ }
+ }
+ AccountExpiry.Missing -> SplashUiSideEffect.NavigateToConnect
+ }
+ }
+}
+
+private sealed interface ValidStartDeviceState {
+ data class LoggedIn(val accountAndDevice: AccountAndDevice) : ValidStartDeviceState
+
+ data object Revoked : ValidStartDeviceState
+
+ data object LoggedOut : ValidStartDeviceState
+}
+
+sealed interface SplashUiSideEffect {
+ data object NavigateToPrivacyDisclaimer : SplashUiSideEffect
+
+ data object NavigateToRevoked : SplashUiSideEffect
+
+ data object NavigateToLogin : SplashUiSideEffect
+
+ data object NavigateToConnect : SplashUiSideEffect
+
+ data object NavigateToOutOfTime : SplashUiSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt
index 3691fc79b5..0cc55b992c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt
@@ -45,7 +45,7 @@ class VoucherDialogViewModel(
}
.shareIn(viewModelScope, SharingStarted.WhileSubscribed())
- var uiState =
+ val uiState =
_shared
.flatMapLatest {
combine(vmState, voucherInput) { state, input ->
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 dfae3df539..80b51a811c 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
@@ -7,12 +7,15 @@ import androidx.lifecycle.viewModelScope
import java.net.InetAddress
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -32,29 +35,32 @@ import net.mullvad.mullvadvpn.model.WireguardConstraints
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.usecase.PortRangeUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
-import net.mullvad.mullvadvpn.util.isValidMtu
-import org.apache.commons.validator.routines.InetAddressValidator
+import net.mullvad.mullvadvpn.util.isCustom
+
+sealed interface VpnSettingsSideEffect {
+ data class ShowToast(val message: String) : VpnSettingsSideEffect
+
+ data object NavigateToDnsDialog : VpnSettingsSideEffect
+}
class VpnSettingsViewModel(
private val repository: SettingsRepository,
- private val inetAddressValidator: InetAddressValidator,
private val resources: Resources,
portRangeUseCase: PortRangeUseCase,
private val relayListUseCase: RelayListUseCase,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
- private val _toastMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
- @Suppress("konsist.ensure public properties use permitted names")
- val toastMessages = _toastMessages.asSharedFlow()
+ private val _uiSideEffect = Channel<VpnSettingsSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
- private val dialogState = MutableStateFlow<VpnSettingsDialogState?>(null)
+ private val customPort = MutableStateFlow<Constraint<Port>?>(null)
private val vmState =
- combine(repository.settingsUpdates, portRangeUseCase.portRanges(), dialogState) {
+ combine(repository.settingsUpdates, portRangeUseCase.portRanges(), customPort) {
settings,
portRanges,
- dialogState ->
+ customWgPort ->
VpnSettingsViewModelState(
mtuValue = settings?.mtuString() ?: "",
isAutoConnectEnabled = settings?.autoConnect ?: false,
@@ -63,12 +69,11 @@ class VpnSettingsViewModel(
customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(),
contentBlockersOptions = settings?.contentBlockersSettings()
?: DefaultDnsOptions(),
- isAllowLanEnabled = settings?.allowLan ?: false,
selectedObfuscation = settings?.selectedObfuscationSettings()
?: SelectedObfuscation.Off,
- dialogState = dialogState,
quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off,
selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any(),
+ customWireguardPort = customWgPort,
availablePortRanges = portRanges
)
}
@@ -87,142 +92,20 @@ class VpnSettingsViewModel(
VpnSettingsUiState.createDefault()
)
- fun onMtuCellClick() {
- dialogState.update { VpnSettingsDialogState.MtuDialog(vmState.value.mtuValue) }
- }
-
- fun onSaveMtuClick(mtuValue: Int) =
+ init {
viewModelScope.launch(dispatcher) {
- if (mtuValue.isValidMtu()) {
- repository.setWireguardMtu(mtuValue)
- }
- hideDialog()
- }
-
- fun onRestoreMtuClick() =
- viewModelScope.launch(dispatcher) {
- repository.setWireguardMtu(null)
- hideDialog()
- }
-
- fun onCancelDialogClick() {
- hideDialog()
- }
-
- fun onLocalNetworkSharingInfoClick() {
- dialogState.update { VpnSettingsDialogState.LocalNetworkSharingInfoDialog }
- }
-
- fun onContentsBlockerInfoClick() {
- dialogState.update { VpnSettingsDialogState.ContentBlockersInfoDialog }
- }
-
- fun onCustomDnsInfoClick() {
- dialogState.update { VpnSettingsDialogState.CustomDnsInfoDialog }
- }
-
- fun onMalwareInfoClick() {
- dialogState.update { VpnSettingsDialogState.MalwareInfoDialog }
- }
-
- fun onDismissInfoClick() {
- hideDialog()
- }
-
- fun onDnsClick(index: Int? = null) {
- val stagedDns =
- if (index == null) {
- StagedDns.NewDns(
- item = CustomDnsItem.default(),
- validationResult = StagedDns.ValidationResult.InvalidAddress
- )
- } else {
- vmState.value.customDnsList.getOrNull(index)?.let { listItem ->
- StagedDns.EditDns(item = listItem, index = index)
+ val initialSettings = repository.settingsUpdates.filterNotNull().first()
+ customPort.update {
+ val initialPort = initialSettings.getWireguardPort()
+ if (initialPort.isCustom()) {
+ initialPort
+ } else {
+ null
}
}
-
- if (stagedDns != null) {
- dialogState.update { VpnSettingsDialogState.DnsDialog(stagedDns) }
- }
- }
-
- fun onDnsInputChange(ipAddress: String) {
- dialogState.update { state ->
- val dialog = state as? VpnSettingsDialogState.DnsDialog ?: return
-
- val error =
- when {
- ipAddress.isBlank() || ipAddress.isValidIp().not() -> {
- StagedDns.ValidationResult.InvalidAddress
- }
- ipAddress.isDuplicateDns((state.stagedDns as? StagedDns.EditDns)?.index) -> {
- StagedDns.ValidationResult.DuplicateAddress
- }
- else -> StagedDns.ValidationResult.Success
- }
-
- return@update VpnSettingsDialogState.DnsDialog(
- stagedDns =
- if (dialog.stagedDns is StagedDns.EditDns) {
- StagedDns.EditDns(
- item =
- CustomDnsItem(
- address = ipAddress,
- isLocal = ipAddress.isLocalAddress()
- ),
- validationResult = error,
- index = dialog.stagedDns.index
- )
- } else {
- StagedDns.NewDns(
- item =
- CustomDnsItem(
- address = ipAddress,
- isLocal = ipAddress.isLocalAddress()
- ),
- validationResult = error
- )
- }
- )
}
}
- fun onSaveDnsClick() =
- viewModelScope.launch(dispatcher) {
- val dialog =
- vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch
-
- if (dialog.stagedDns.isValid().not()) return@launch
-
- val updatedList =
- vmState.value.customDnsList
- .toMutableList()
- .map { it.address }
- .toMutableList()
- .let { activeList ->
- if (dialog.stagedDns is StagedDns.EditDns) {
- activeList
- .apply {
- set(dialog.stagedDns.index, dialog.stagedDns.item.address)
- }
- .asInetAddressList()
- } else {
- activeList
- .apply { add(dialog.stagedDns.item.address) }
- .asInetAddressList()
- }
- }
-
- repository.setDnsOptions(
- isCustomDnsEnabled = true,
- dnsList = updatedList,
- contentBlockersOptions = vmState.value.contentBlockersOptions
- )
-
- hideDialog()
- }
-
fun onToggleAutoConnect(isEnabled: Boolean) {
viewModelScope.launch(dispatcher) { repository.setAutoConnect(isEnabled) }
}
@@ -231,12 +114,19 @@ class VpnSettingsViewModel(
viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) }
}
- fun onToggleDnsClick(isEnabled: Boolean) {
- updateCustomDnsState(isEnabled)
- if (isEnabled && vmState.value.customDnsList.isEmpty()) {
- onDnsClick(null)
+ fun onDnsDialogDismissed() {
+ if (vmState.value.customDnsList.isEmpty()) {
+ onToggleCustomDns(false)
+ }
+ }
+
+ fun onToggleCustomDns(enable: Boolean) {
+ repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default)
+ if (enable && vmState.value.customDnsList.isEmpty()) {
+ viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog) }
+ } else {
+ showApplySettingChangesWarningToast()
}
- showApplySettingChangesWarningToast()
}
fun onToggleBlockAds(isEnabled: Boolean) {
@@ -281,29 +171,9 @@ class VpnSettingsViewModel(
showApplySettingChangesWarningToast()
}
- fun onRemoveDnsClick() =
- viewModelScope.launch(dispatcher) {
- val dialog =
- vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch
-
- val updatedList =
- vmState.value.customDnsList
- .toMutableList()
- .filter { it.address != dialog.stagedDns.item.address }
- .map { it.address }
- .asInetAddressList()
-
- repository.setDnsOptions(
- isCustomDnsEnabled = vmState.value.isCustomDnsEnabled && updatedList.isNotEmpty(),
- dnsList = updatedList,
- contentBlockersOptions = vmState.value.contentBlockersOptions
- )
- hideDialog()
- }
-
fun onStopEvent() {
if (vmState.value.customDnsList.isEmpty()) {
- updateCustomDnsState(false)
+ repository.setDnsState(DnsState.Default)
}
}
@@ -318,31 +188,27 @@ class VpnSettingsViewModel(
}
}
- fun onObfuscationInfoClick() {
- dialogState.update { VpnSettingsDialogState.ObfuscationInfoDialog }
- }
-
fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) {
viewModelScope.launch(dispatcher) {
repository.setWireguardQuantumResistant(quantumResistant)
}
}
- fun onQuantumResistanceInfoClicked() {
- dialogState.update { VpnSettingsDialogState.QuantumResistanceInfoDialog }
- }
-
fun onWireguardPortSelected(port: Constraint<Port>) {
+ if (port.isCustom()) {
+ customPort.update { port }
+ }
relayListUseCase.updateSelectedWireguardConstraints(WireguardConstraints(port = port))
- hideDialog()
}
- fun onWireguardPortInfoClicked() {
- dialogState.update { VpnSettingsDialogState.WireguardPortInfoDialog }
- }
-
- fun onShowCustomPortDialog() {
- dialogState.update { VpnSettingsDialogState.CustomPortDialog }
+ fun resetCustomPort() {
+ customPort.update { null }
+ // If custom port was selected, update selection to be any.
+ if (vmState.value.selectedWireguardPort.isCustom()) {
+ relayListUseCase.updateSelectedWireguardConstraints(
+ WireguardConstraints(port = Constraint.Any())
+ )
+ }
}
private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) =
@@ -354,26 +220,6 @@ class VpnSettingsViewModel(
)
}
- private fun hideDialog() {
- dialogState.update { null }
- }
-
- fun onCancelDns() {
- if (
- vmState.value.dialogState is VpnSettingsDialogState.DnsDialog &&
- vmState.value.customDnsList.isEmpty()
- ) {
- onToggleDnsClick(false)
- }
- hideDialog()
- }
-
- private fun String.isDuplicateDns(stagedIndex: Int? = null): Boolean {
- return vmState.value.customDnsList
- .filterIndexed { index, listItem -> index != stagedIndex && listItem.address == this }
- .isNotEmpty()
- }
-
private fun List<String>.asInetAddressList(): List<InetAddress> {
return try {
map { InetAddress.getByName(it) }
@@ -408,32 +254,20 @@ class VpnSettingsViewModel(
(relaySettings as RelaySettings.Normal).relayConstraints.wireguardConstraints.port
}
- private fun String.isValidIp(): Boolean {
- return inetAddressValidator.isValid(this)
- }
-
- private fun String.isLocalAddress(): Boolean {
- return isValidIp() && InetAddress.getByName(this).isLocalAddress()
- }
-
private fun InetAddress.isLocalAddress(): Boolean {
return isLinkLocalAddress || isSiteLocalAddress
}
- private fun updateCustomDnsState(isEnabled: Boolean) {
- viewModelScope.launch(dispatcher) {
- repository.setDnsOptions(
- isEnabled,
- dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(),
- contentBlockersOptions = vmState.value.contentBlockersOptions
+ private fun showApplySettingChangesWarningToast() {
+ viewModelScope.launch {
+ _uiSideEffect.send(
+ VpnSettingsSideEffect.ShowToast(
+ resources.getString(R.string.settings_changes_effect_warning_short)
+ )
)
}
}
- private fun showApplySettingChangesWarningToast() {
- _toastMessages.tryEmit(resources.getString(R.string.settings_changes_effect_warning_short))
- }
-
companion object {
private const val EMPTY_STRING = ""
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt
index 2ebc2b397c..fd236e8405 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.viewmodel
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog
import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
import net.mullvad.mullvadvpn.model.Constraint
import net.mullvad.mullvadvpn.model.DefaultDnsOptions
@@ -14,14 +13,13 @@ data class VpnSettingsViewModelState(
val isAutoConnectEnabled: Boolean,
val isLocalNetworkSharingEnabled: Boolean,
val isCustomDnsEnabled: Boolean,
- val isAllowLanEnabled: Boolean,
val customDnsList: List<CustomDnsItem>,
val contentBlockersOptions: DefaultDnsOptions,
val selectedObfuscation: SelectedObfuscation,
val quantumResistant: QuantumResistantState,
val selectedWireguardPort: Constraint<Port>,
+ val customWireguardPort: Constraint<Port>?,
val availablePortRanges: List<PortRange>,
- val dialogState: VpnSettingsDialogState?,
) {
fun toUiState(): VpnSettingsUiState =
VpnSettingsUiState(
@@ -31,12 +29,11 @@ data class VpnSettingsViewModelState(
isCustomDnsEnabled,
customDnsList,
contentBlockersOptions,
- isAllowLanEnabled,
selectedObfuscation,
quantumResistant,
selectedWireguardPort,
+ customWireguardPort,
availablePortRanges,
- dialogState.toUi(this@VpnSettingsViewModelState)
)
companion object {
@@ -50,86 +47,15 @@ data class VpnSettingsViewModelState(
isCustomDnsEnabled = false,
customDnsList = listOf(),
contentBlockersOptions = DefaultDnsOptions(),
- isAllowLanEnabled = false,
- dialogState = null,
selectedObfuscation = SelectedObfuscation.Auto,
quantumResistant = QuantumResistantState.Off,
selectedWireguardPort = Constraint.Any(),
+ customWireguardPort = null,
availablePortRanges = emptyList()
)
}
}
-private fun VpnSettingsDialogState?.toUi(
- vpnSettingsViewModelState: VpnSettingsViewModelState
-): VpnSettingsDialog? =
- when (this) {
- VpnSettingsDialogState.ContentBlockersInfoDialog -> VpnSettingsDialog.ContentBlockersInfo
- VpnSettingsDialogState.CustomDnsInfoDialog -> VpnSettingsDialog.CustomDnsInfo
- VpnSettingsDialogState.CustomPortDialog ->
- VpnSettingsDialog.CustomPort(vpnSettingsViewModelState.availablePortRanges)
- is VpnSettingsDialogState.DnsDialog -> VpnSettingsDialog.Dns(stagedDns)
- VpnSettingsDialogState.LocalNetworkSharingInfoDialog ->
- VpnSettingsDialog.LocalNetworkSharingInfo
- VpnSettingsDialogState.MalwareInfoDialog -> VpnSettingsDialog.MalwareInfo
- is VpnSettingsDialogState.MtuDialog -> VpnSettingsDialog.Mtu(mtuEditValue)
- VpnSettingsDialogState.ObfuscationInfoDialog -> VpnSettingsDialog.ObfuscationInfo
- VpnSettingsDialogState.QuantumResistanceInfoDialog ->
- VpnSettingsDialog.QuantumResistanceInfo
- VpnSettingsDialogState.WireguardPortInfoDialog ->
- VpnSettingsDialog.WireguardPortInfo(vpnSettingsViewModelState.availablePortRanges)
- null -> null
- }
-
-sealed class VpnSettingsDialogState {
-
- data class MtuDialog(val mtuEditValue: String) : VpnSettingsDialogState()
-
- data class DnsDialog(val stagedDns: StagedDns) : VpnSettingsDialogState()
-
- data object LocalNetworkSharingInfoDialog : VpnSettingsDialogState()
-
- data object ContentBlockersInfoDialog : VpnSettingsDialogState()
-
- data object CustomDnsInfoDialog : VpnSettingsDialogState()
-
- data object MalwareInfoDialog : VpnSettingsDialogState()
-
- data object ObfuscationInfoDialog : VpnSettingsDialogState()
-
- data object QuantumResistanceInfoDialog : VpnSettingsDialogState()
-
- data object WireguardPortInfoDialog : VpnSettingsDialogState()
-
- data object CustomPortDialog : VpnSettingsDialogState()
-}
-
-sealed interface StagedDns {
- val item: CustomDnsItem
- val validationResult: ValidationResult
-
- data class NewDns(
- override val item: CustomDnsItem,
- override val validationResult: ValidationResult = ValidationResult.Success,
- ) : StagedDns
-
- data class EditDns(
- override val item: CustomDnsItem,
- override val validationResult: ValidationResult = ValidationResult.Success,
- val index: Int
- ) : StagedDns
-
- sealed class ValidationResult {
- data object Success : ValidationResult()
-
- data object InvalidAddress : ValidationResult()
-
- data object DuplicateAddress : ValidationResult()
- }
-
- fun isValid() = (validationResult is ValidationResult.Success)
-}
-
data class CustomDnsItem(val address: String, val isLocal: Boolean) {
companion object {
private const val EMPTY_STRING = ""
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 69e9764d4f..7c77d183ca 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
@@ -1,25 +1,25 @@
package net.mullvad.mullvadvpn.viewmodel
-import android.app.Activity
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
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
-import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
@@ -27,13 +27,12 @@ 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
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.util.toPaymentState
-import org.joda.time.DateTime
@OptIn(FlowPreview::class)
class WelcomeViewModel(
@@ -41,10 +40,11 @@ class WelcomeViewModel(
private val deviceRepository: DeviceRepository,
private val serviceConnectionManager: ServiceConnectionManager,
private val paymentUseCase: PaymentUseCase,
+ private val outOfTimeUseCase: OutOfTimeUseCase,
private val pollAccountExpiry: Boolean = true
) : ViewModel() {
- private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
- val uiSideEffect = _uiSideEffect.asSharedFlow()
+ private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
val uiState =
serviceConnectionManager.connectionState
@@ -62,14 +62,13 @@ class WelcomeViewModel(
it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
},
paymentUseCase.paymentAvailability,
- paymentUseCase.purchaseResult
- ) { tunnelState, deviceState, paymentAvailability, purchaseResult ->
+ ) { tunnelState, deviceState, paymentAvailability ->
WelcomeUiState(
tunnelState = tunnelState,
accountNumber = deviceState.token(),
deviceName = deviceState.deviceName(),
+ showSitePayment = IS_PLAY_BUILD.not(),
billingPaymentState = paymentAvailability?.toPaymentState(),
- paymentDialogData = purchaseResult?.toPaymentDialogData()
)
}
}
@@ -77,24 +76,16 @@ class WelcomeViewModel(
init {
viewModelScope.launch {
- accountRepository.accountExpiryState.collectLatest { accountExpiry ->
- accountExpiry.date()?.let { expiry ->
- val tomorrow = DateTime.now().plusHours(20)
-
- if (expiry.isAfter(tomorrow)) {
- // Reset purchase state
- paymentUseCase.resetPurchaseResult()
- _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
- }
- }
- }
- }
- viewModelScope.launch {
while (pollAccountExpiry) {
updateAccountExpiry()
delay(ACCOUNT_EXPIRY_POLL_INTERVAL)
}
}
+ viewModelScope.launch {
+ outOfTimeUseCase.isOutOfTime().first { it == false }
+ paymentUseCase.resetPurchaseResult()
+ _uiSideEffect.send(UiSideEffect.OpenConnectScreen)
+ }
verifyPurchases()
fetchPaymentAvailability()
}
@@ -104,7 +95,7 @@ class WelcomeViewModel(
fun onSitePaymentClick() {
viewModelScope.launch {
- _uiSideEffect.tryEmit(
+ _uiSideEffect.send(
UiSideEffect.OpenAccountView(
serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
)
@@ -112,10 +103,6 @@ class WelcomeViewModel(
}
}
- fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
- viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
- }
-
private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
@@ -123,7 +110,6 @@ class WelcomeViewModel(
}
}
- @OptIn(FlowPreview::class)
private fun fetchPaymentAvailability() {
viewModelScope.launch { paymentUseCase.queryPaymentAvailability() }
}
@@ -135,7 +121,7 @@ class WelcomeViewModel(
// should check payment availability and verify any purchases to handle potential errors.
if (success) {
updateAccountExpiry()
- _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
+ // Emission of out of time navigation is handled by launch in onStart
} else {
fetchPaymentAvailability()
verifyPurchases() // Attempt to verify again
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt
index c02e755951..282d1d3a27 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt
@@ -11,10 +11,8 @@ import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertIs
-import kotlin.test.assertNull
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.common.test.assertLists
@@ -32,7 +30,6 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -161,29 +158,6 @@ class AccountViewModelTest {
}
@Test
- fun testBillingUserCancelled() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Cancelled
- purchaseResult.value = result
- every { result.toPaymentDialogData() } returns null
-
- // Act, Assert
- viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
- }
-
- @Test
- fun testBillingPurchaseSuccess() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Success
- val expectedData: PaymentDialogData = mockk()
- purchaseResult.value = result
- every { result.toPaymentDialogData() } returns expectedData
-
- // Act, Assert
- viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
- }
-
- @Test
fun testStartBillingPayment() {
// Arrange
val mockProductId = ProductId("MOCK")
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt
index e223a12539..3350178ca3 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt
@@ -8,15 +8,17 @@ import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.mockkStatic
import io.mockk.unmockkAll
-import io.mockk.verify
-import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import org.junit.After
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
class ChangelogViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
@MockK private lateinit var mockedChangelogRepository: ChangelogRepository
@@ -28,7 +30,6 @@ class ChangelogViewModelTest {
mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS)
every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just
Runs
- viewModel = ChangelogViewModel(mockedChangelogRepository, 1, false)
}
@After
@@ -37,54 +38,41 @@ class ChangelogViewModelTest {
}
@Test
- fun testInitialState() = runTest {
- // Arrange, Act, Assert
- viewModel.uiState.test { assertEquals(ChangelogDialogUiState.Hide, awaitItem()) }
+ fun testUpToDateVersionCodeShouldNotEmitChangelog() = runTest {
+ // Arrange
+ every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns
+ buildVersionCode
+ viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false)
+
+ // If we have the most up to date version code, we should not show the changelog dialog
+ viewModel.uiSideEffect.test { expectNoEvents() }
}
@Test
- fun testShowAndDismissChangelogDialog() = runTest {
- viewModel.uiState.test {
- // Arrange
- val fakeList = listOf("test")
- every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns
- -1
- every { mockedChangelogRepository.getLastVersionChanges() } returns fakeList
-
- // Assert initial ui state
- assertEquals(ChangelogDialogUiState.Hide, awaitItem())
+ fun testNotUpToDateVersionCodeShouldEmitChangelog() = runTest {
+ // Arrange
+ every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1
+ every { mockedChangelogRepository.getLastVersionChanges() } returns listOf("bla", "bla")
- // Refresh and verify that the dialog should be shown
- viewModel.refreshChangelogDialogUiState()
- assertEquals(ChangelogDialogUiState.Show(fakeList), awaitItem())
-
- // Dismiss dialog and verify that the dialog should be hidden
- viewModel.dismissChangelogDialog()
- assertEquals(ChangelogDialogUiState.Hide, awaitItem())
- verify { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(1) }
- }
+ viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false)
+ // Given a new version with a change log we should return it
+ viewModel.uiSideEffect.test { assertNotNull(awaitItem()) }
}
@Test
- fun testShowCaseChangelogWithEmptyListDialog() = runTest {
- viewModel.uiState.test {
- // Arrange
- val fakeEmptyList = emptyList<String>()
- every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns
- -1
- every { mockedChangelogRepository.getLastVersionChanges() } returns fakeEmptyList
-
- // Assert initial ui state
- assertEquals(ChangelogDialogUiState.Hide, awaitItem())
+ fun testEmptyChangelogShouldNotEmitChangelog() = runTest {
+ // Arrange
+ every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1
+ every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList()
- // Refresh and verify that the Ui state remain same due list being empty
- viewModel.refreshChangelogDialogUiState()
- expectNoEvents()
- }
+ viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false)
+ // Given a new version with a change log we should not return it
+ viewModel.uiSideEffect.test { expectNoEvents() }
}
companion object {
private const val EVENT_NOTIFIER_EXTENSION_CLASS =
"net.mullvad.talpid.util.EventNotifierExtensionsKt"
+ private const val buildVersionCode = 10
}
}
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 345a57df80..35898df4ab 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
@@ -39,11 +39,11 @@ 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.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
import net.mullvad.mullvadvpn.util.appVersionCallbackFlow
import net.mullvad.talpid.tunnel.ErrorState
-import net.mullvad.talpid.tunnel.ErrorStateCause
import net.mullvad.talpid.util.EventNotifier
import org.junit.After
import org.junit.Before
@@ -103,6 +103,10 @@ class ConnectViewModelTest {
// Flows
private val selectedRelayFlow = MutableStateFlow<RelayItem?>(null)
+ // Out Of Time Use Case
+ private val outOfTimeUseCase: OutOfTimeUseCase = mockk()
+ private val outOfTimeViewFlow = MutableStateFlow(false)
+
@Before
fun setup() {
mockkStatic(CACHE_EXTENSION_CLASS)
@@ -136,6 +140,7 @@ class ConnectViewModelTest {
// Flows
every { mockRelayListUseCase.selectedRelayItem() } returns selectedRelayFlow
+ every { outOfTimeUseCase.isOutOfTime() } returns outOfTimeViewFlow
viewModel =
ConnectViewModel(
serviceConnectionManager = mockServiceConnectionManager,
@@ -144,6 +149,7 @@ class ConnectViewModelTest {
inAppNotificationController = mockInAppNotificationController,
relayListUseCase = mockRelayListUseCase,
newDeviceNotificationUseCase = mockk(),
+ outOfTimeUseCase = outOfTimeUseCase,
paymentUseCase = mockPaymentUseCase
)
}
@@ -342,8 +348,6 @@ class ConnectViewModelTest {
fun testOutOfTimeUiSideEffect() =
runTest(testCoroutineRule.testDispatcher) {
// Arrange
- val errorStateCause = ErrorStateCause.AuthFailed("[EXPIRED_ACCOUNT]")
- val tunnelRealStateTestItem = TunnelState.Error(ErrorState(errorStateCause, true))
val deferred = async { viewModel.uiSideEffect.first() }
// Act
@@ -352,12 +356,12 @@ class ConnectViewModelTest {
serviceConnectionState.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
locationSlot.captured.invoke(mockLocation)
- eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)
+ outOfTimeViewFlow.value = true
awaitItem()
}
// Assert
- assertIs<ConnectViewModel.UiSideEffect.OpenOutOfTimeView>(deferred.await())
+ assertIs<ConnectViewModel.UiSideEffect.OutOfTime>(deferred.await())
}
companion object {
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index 7eb35404d0..c402a3103e 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.model.AccountCreationResult
+import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.AccountHistory
import net.mullvad.mullvadvpn.model.AccountToken
import net.mullvad.mullvadvpn.model.DeviceListEvent
@@ -28,6 +29,7 @@ import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import org.joda.time.DateTime
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
@@ -113,6 +115,8 @@ class LoginViewModelTest {
val uiStates = loginViewModel.uiState.testIn(backgroundScope)
val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope)
coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok
+ coEvery { mockedAccountRepository.accountExpiryState } returns
+ MutableStateFlow(AccountExpiry.Available(DateTime.now().plusDays(3)))
// Act, Assert
uiStates.skipDefaultItem()
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 dad51eab59..0232f12e89 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.viewmodel
-import android.app.Activity
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import io.mockk.coEvery
@@ -12,18 +11,15 @@ import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertIs
-import kotlin.test.assertNull
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.common.test.assertLists
import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
-import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.DeviceState
@@ -37,8 +33,8 @@ 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.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
-import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.talpid.util.EventNotifier
import org.joda.time.DateTime
import org.joda.time.ReadableInstant
@@ -50,12 +46,13 @@ import org.junit.Test
class OutOfTimeViewModelTest {
@get:Rule val testCoroutineRule = TestCoroutineRule()
- private val serviceConnectionState =
+ private val serviceConnectionStateFlow =
MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
- private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
- private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial)
- private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
- private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
+ private val accountExpiryStateFlow = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
+ private val deviceStateFlow = MutableStateFlow<DeviceState>(DeviceState.Initial)
+ private val paymentAvailabilityFlow = MutableStateFlow<PaymentAvailability?>(null)
+ private val purchaseResultFlow = MutableStateFlow<PurchaseResult?>(null)
+ private val outOfTimeFlow = MutableStateFlow(true)
// Service connections
private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
@@ -68,6 +65,7 @@ class OutOfTimeViewModelTest {
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: OutOfTimeViewModel
@@ -76,19 +74,21 @@ class OutOfTimeViewModelTest {
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS)
- every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow
every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy
every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState
- every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+ every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow
- every { mockDeviceRepository.deviceState } returns deviceState
+ every { mockDeviceRepository.deviceState } returns deviceStateFlow
- coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow
- coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability
+ coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow
+
+ coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow
viewModel =
OutOfTimeViewModel(
@@ -96,6 +96,7 @@ class OutOfTimeViewModelTest {
serviceConnectionManager = mockServiceConnectionManager,
deviceRepository = mockDeviceRepository,
paymentUseCase = mockPaymentUseCase,
+ outOfTimeUseCase = mockOutOfTimeUseCase,
pollAccountExpiry = false
)
}
@@ -134,7 +135,7 @@ class OutOfTimeViewModelTest {
viewModel.uiState.test {
assertEquals(OutOfTimeUiState(deviceName = ""), awaitItem())
eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)
- serviceConnectionState.value =
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
val result = awaitItem()
assertEquals(tunnelRealStateTestItem, result.tunnelState)
@@ -150,7 +151,7 @@ class OutOfTimeViewModelTest {
// Act, Assert
viewModel.uiSideEffect.test {
- accountExpiryState.value = AccountExpiry.Available(mockExpiryDate)
+ outOfTimeFlow.value = false
val action = awaitItem()
assertIs<OutOfTimeViewModel.UiSideEffect.OpenConnectScreen>(action)
}
@@ -174,8 +175,8 @@ class OutOfTimeViewModelTest {
fun testBillingProductsUnavailableState() = runTest {
// Arrange
val productsUnavailable = PaymentAvailability.ProductsUnavailable
- paymentAvailability.value = productsUnavailable
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = productsUnavailable
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -189,8 +190,8 @@ class OutOfTimeViewModelTest {
fun testBillingProductsGenericErrorState() = runTest {
// Arrange
val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk())
- paymentAvailability.value = paymentAvailabilityError
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = paymentAvailabilityError
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -204,8 +205,8 @@ class OutOfTimeViewModelTest {
fun testBillingProductsBillingErrorState() = runTest {
// Arrange
val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable
- paymentAvailability.value = paymentAvailabilityError
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = paymentAvailabilityError
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -221,8 +222,8 @@ class OutOfTimeViewModelTest {
val mockProduct: PaymentProduct = mockk()
val expectedProductList = listOf(mockProduct)
val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct))
- paymentAvailability.value = productsAvailable
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = productsAvailable
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -234,44 +235,6 @@ class OutOfTimeViewModelTest {
}
@Test
- fun testBillingUserCancelled() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Cancelled
- purchaseResult.value = result
- every { result.toPaymentDialogData() } returns null
-
- // Act, Assert
- viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
- }
-
- @Test
- fun testBillingPurchaseSuccess() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Success
- val expectedData: PaymentDialogData = mockk()
- purchaseResult.value = result
- serviceConnectionState.value =
- ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- every { result.toPaymentDialogData() } returns expectedData
-
- // Act, Assert
- viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
- }
-
- @Test
- fun testStartBillingPayment() {
- // Arrange
- val mockProductId = ProductId("MOCK")
- val mockActivityProvider = mockk<() -> Activity>()
-
- // Act
- viewModel.startBillingPayment(mockProductId, mockActivityProvider)
-
- // Assert
- coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) }
- }
-
- @Test
fun testOnClosePurchaseResultDialogSuccessful() {
// Arrange
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt
new file mode 100644
index 0000000000..665e23c3d4
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt
@@ -0,0 +1,70 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class PaymentViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true)
+
+ private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
+
+ private lateinit var viewModel: PaymentViewModel
+
+ @Before
+ fun setUp() {
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+
+ viewModel = PaymentViewModel(paymentUseCase = mockPaymentUseCase)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun testBillingUserCancelled() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Cancelled
+ purchaseResult.value = result
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(PaymentDialogData(), awaitItem().paymentDialogData)
+ purchaseResult.value = result
+ }
+
+ viewModel.uiSideEffect.test {
+ assertEquals(PaymentUiSideEffect.PaymentCancelled, awaitItem())
+ }
+ }
+
+ @Test
+ fun testBillingPurchaseSuccess() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Success
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem()
+ purchaseResult.value = result
+ assertEquals(result.toPaymentDialogData(), awaitItem().paymentDialogData)
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt
new file mode 100644
index 0000000000..5726c6249c
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt
@@ -0,0 +1,192 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.impl.annotations.MockK
+import io.mockk.verify
+import kotlin.test.assertEquals
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
+import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult
+import net.mullvad.mullvadvpn.dataproxy.UserReport
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.repository.ProblemReportRepository
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class ReportProblemViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ @MockK private lateinit var mockMullvadProblemReport: MullvadProblemReport
+
+ @MockK(relaxed = true) private lateinit var mockProblemReportRepository: ProblemReportRepository
+
+ private val problemReportFlow = MutableStateFlow(UserReport("", ""))
+
+ private lateinit var viewModel: ReportProblemViewModel
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ coEvery { mockMullvadProblemReport.collectLogs() } returns true
+ coEvery { mockProblemReportRepository.problemReport } returns problemReportFlow
+ viewModel = ReportProblemViewModel(mockMullvadProblemReport, mockProblemReportRepository)
+ }
+
+ @After
+ fun tearDown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ }
+
+ @Test
+ fun sendReportFailedToCollectLogs() = runTest {
+ // Arrange
+ coEvery { mockMullvadProblemReport.sendReport(any()) } returns
+ SendProblemReportResult.Error.CollectLog
+ val email = "my@email.com"
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(null, awaitItem().sendingState)
+ viewModel.sendReport(email, "My description")
+ assertEquals(SendingReportUiState.Sending, awaitItem().sendingState)
+ assertEquals(
+ SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog),
+ awaitItem().sendingState
+ )
+ }
+ }
+
+ @Test
+ fun sendReportFailedToSendReport() = runTest {
+ // Arrange
+ coEvery { mockMullvadProblemReport.sendReport(any()) } returns
+ SendProblemReportResult.Error.SendReport
+ val email = "my@email.com"
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(null, awaitItem().sendingState)
+ viewModel.sendReport(email, "My description")
+ assertEquals(SendingReportUiState.Sending, awaitItem().sendingState)
+ assertEquals(
+ SendingReportUiState.Error(SendProblemReportResult.Error.SendReport),
+ awaitItem().sendingState
+ )
+ }
+ }
+
+ @Test
+ fun sendReportWithoutEmailSuccessfully() = runTest {
+ // Arrange
+ coEvery { mockMullvadProblemReport.sendReport(any()) } returns
+ SendProblemReportResult.Success
+ val email = ""
+ val description = "My description"
+
+ coEvery { mockProblemReportRepository.setDescription(any()) } answers
+ {
+ problemReportFlow.value = problemReportFlow.value.copy(description = arg(0))
+ }
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(ReportProblemUiState(), awaitItem())
+ viewModel.updateDescription(description)
+ assertEquals(ReportProblemUiState(description = description), awaitItem())
+
+ viewModel.sendReport(email, description, true)
+ assertEquals(
+ ReportProblemUiState(SendingReportUiState.Sending, email, description),
+ awaitItem()
+ )
+ assertEquals(
+ ReportProblemUiState(
+ SendingReportUiState.Success(null),
+ "",
+ "",
+ ),
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun sendReportSuccessfully() = runTest {
+ // Arrange
+ coEvery { mockMullvadProblemReport.collectLogs() } returns true
+ coEvery { mockMullvadProblemReport.sendReport(any()) } returns
+ SendProblemReportResult.Success
+ val email = "my@email.com"
+ val description = "My description"
+
+ // This might look a bit weird, and is not optimal. An alternative would be to use the real
+ // ProblemReportRepository, but that would complicate the other tests. This is a compromise.
+ coEvery { mockProblemReportRepository.setEmail(any()) } answers
+ {
+ problemReportFlow.value = problemReportFlow.value.copy(email = arg(0))
+ }
+ coEvery { mockProblemReportRepository.setDescription(any()) } answers
+ {
+ problemReportFlow.value = problemReportFlow.value.copy(description = arg(0))
+ }
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(awaitItem(), ReportProblemUiState(null, "", ""))
+ viewModel.updateEmail(email)
+ awaitItem()
+ viewModel.updateDescription(description)
+ awaitItem()
+
+ viewModel.sendReport(email, description)
+
+ assertEquals(
+ ReportProblemUiState(
+ SendingReportUiState.Sending,
+ email,
+ description,
+ ),
+ awaitItem()
+ )
+ assertEquals(
+ ReportProblemUiState(
+ SendingReportUiState.Success(email),
+ "",
+ "",
+ ),
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun testUpdateEmail() = runTest {
+ // Arrange
+ val email = "my@email.com"
+
+ // Act
+ viewModel.updateEmail(email)
+
+ // Assert
+ verify { mockProblemReportRepository.setEmail(email) }
+ }
+
+ @Test
+ fun testUpdateDescription() = runTest {
+ // Arrange
+ val description = "My description"
+
+ // Act
+ viewModel.updateDescription(description)
+
+ // Assert
+ verify { mockProblemReportRepository.setDescription(description) }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt
index 74d7d80c19..5ad1af1182 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt
@@ -120,10 +120,10 @@ class SelectLocationViewModelTest {
every { mockRelayListUseCase.updateSelectedRelayLocation(mockLocation) } returns Unit
// Act, Assert
- viewModel.uiCloseAction.test {
+ viewModel.uiSideEffect.test {
viewModel.selectRelay(mockRelayItem)
// Await an empty item
- assertEquals(Unit, awaitItem())
+ assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem())
verify {
connectionProxyMock.connect()
mockRelayListUseCase.updateSelectedRelayLocation(mockLocation)
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
index f8736eb823..0ac13777cd 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
@@ -9,15 +9,11 @@ import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertIs
-import kotlin.test.assertTrue
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
-import net.mullvad.mullvadvpn.lib.common.test.assertLists
import net.mullvad.mullvadvpn.model.Constraint
import net.mullvad.mullvadvpn.model.Port
import net.mullvad.mullvadvpn.model.PortRange
@@ -31,7 +27,6 @@ import net.mullvad.mullvadvpn.model.WireguardTunnelOptions
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.usecase.PortRangeUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
-import org.apache.commons.validator.routines.InetAddressValidator
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -41,7 +36,6 @@ class VpnSettingsViewModelTest {
@get:Rule val testCoroutineRule = TestCoroutineRule()
private val mockSettingsRepository: SettingsRepository = mockk()
- private val mockInetAddressValidator: InetAddressValidator = mockk()
private val mockResources: Resources = mockk()
private val mockPortRangeUseCase: PortRangeUseCase = mockk()
private val mockRelayListUseCase: RelayListUseCase = mockk()
@@ -59,7 +53,6 @@ class VpnSettingsViewModelTest {
viewModel =
VpnSettingsViewModel(
repository = mockSettingsRepository,
- inetAddressValidator = mockInetAddressValidator,
resources = mockResources,
portRangeUseCase = mockPortRangeUseCase,
relayListUseCase = mockRelayListUseCase,
@@ -133,6 +126,7 @@ class VpnSettingsViewModelTest {
viewModel.uiState.test {
assertIs<Constraint.Any<Port>>(awaitItem().selectedWireguardPort)
mockSettingsUpdate.value = mockSettings
+ assertEquals(expectedPort, awaitItem().customWireguardPort)
assertEquals(expectedPort, awaitItem().selectedWireguardPort)
}
}
@@ -152,23 +146,4 @@ class VpnSettingsViewModelTest {
mockRelayListUseCase.updateSelectedWireguardConstraints(wireguardConstraints)
}
}
-
- @Test
- fun test_update_port_range_state() = runTest {
- // Arrange
- val expectedPortRange = listOf<PortRange>(mockk(), mockk())
- val mockSettings: Settings = mockk(relaxed = true)
-
- every { mockSettings.relaySettings } returns mockk<RelaySettings.Normal>(relaxed = true)
- portRangeFlow.value = expectedPortRange
-
- // Act, Assert
- viewModel.uiState.test {
- assertIs<VpnSettingsUiState>(awaitItem())
- viewModel.onWireguardPortInfoClicked()
- val state = awaitItem()
- assertTrue { state.dialog is VpnSettingsDialog.WireguardPortInfo }
- assertLists(expectedPortRange, state.availablePortRanges)
- }
- }
}
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 e958df9337..433a9f5709 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
@@ -1,28 +1,23 @@
package net.mullvad.mullvadvpn.viewmodel
-import android.app.Activity
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import io.mockk.coEvery
-import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlin.test.assertEquals
import kotlin.test.assertIs
-import kotlin.test.assertNull
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.common.test.assertLists
import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
-import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.AccountAndDevice
import net.mullvad.mullvadvpn.model.AccountExpiry
@@ -37,8 +32,8 @@ 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.mullvadvpn.util.toPaymentDialogData
import net.mullvad.talpid.util.EventNotifier
import org.joda.time.DateTime
import org.joda.time.ReadableInstant
@@ -50,12 +45,13 @@ import org.junit.Test
class WelcomeViewModelTest {
@get:Rule val testCoroutineRule = TestCoroutineRule()
- private val serviceConnectionState =
+ private val serviceConnectionStateFlow =
MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
- private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial)
- private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
- private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
- private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
+ private val deviceStateFlow = MutableStateFlow<DeviceState>(DeviceState.Initial)
+ 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()
@@ -68,6 +64,7 @@ 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
@@ -76,19 +73,21 @@ class WelcomeViewModelTest {
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS)
- every { mockDeviceRepository.deviceState } returns deviceState
+ every { mockDeviceRepository.deviceState } returns deviceStateFlow
- every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow
every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy
every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState
- every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+ every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow
- coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow
- coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability
+ coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow
+
+ coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow
viewModel =
WelcomeViewModel(
@@ -96,6 +95,7 @@ class WelcomeViewModelTest {
deviceRepository = mockDeviceRepository,
serviceConnectionManager = mockServiceConnectionManager,
paymentUseCase = mockPaymentUseCase,
+ outOfTimeUseCase = mockOutOfTimeUseCase,
pollAccountExpiry = false
)
}
@@ -134,7 +134,7 @@ class WelcomeViewModelTest {
viewModel.uiState.test {
assertEquals(WelcomeUiState(), awaitItem())
eventNotifierTunnelUiState.notify(tunnelUiStateTestItem)
- serviceConnectionState.value =
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
val result = awaitItem()
assertEquals(tunnelUiStateTestItem, result.tunnelState)
@@ -142,27 +142,26 @@ class WelcomeViewModelTest {
}
@Test
- fun testUpdateAccountNumber() =
- runTest(testCoroutineRule.testDispatcher) {
- // Arrange
- val expectedAccountNumber = "4444555566667777"
- val device: Device = mockk()
- every { device.displayName() } returns ""
+ fun testUpdateAccountNumber() = runTest {
+ // Arrange
+ val expectedAccountNumber = "4444555566667777"
+ val device: Device = mockk()
+ every { device.displayName() } returns ""
- // Act, Assert
- viewModel.uiState.test {
- assertEquals(WelcomeUiState(), awaitItem())
- serviceConnectionState.value =
- ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- deviceState.value =
- DeviceState.LoggedIn(
- accountAndDevice =
- AccountAndDevice(account_token = expectedAccountNumber, device = device)
- )
- val result = awaitItem()
- assertEquals(expectedAccountNumber, result.accountNumber)
- }
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(WelcomeUiState(), awaitItem())
+ paymentAvailabilityFlow.value = null
+ deviceStateFlow.value =
+ DeviceState.LoggedIn(
+ accountAndDevice =
+ AccountAndDevice(account_token = expectedAccountNumber, device = device)
+ )
+ serviceConnectionStateFlow.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ assertEquals(expectedAccountNumber, awaitItem().accountNumber)
}
+ }
@Test
fun testOpenConnectScreen() =
@@ -173,7 +172,7 @@ class WelcomeViewModelTest {
// Act, Assert
viewModel.uiSideEffect.test {
- accountExpiryState.value = AccountExpiry.Available(mockExpiryDate)
+ outOfTimeFlow.value = false
val action = awaitItem()
assertIs<WelcomeViewModel.UiSideEffect.OpenConnectScreen>(action)
}
@@ -188,8 +187,8 @@ class WelcomeViewModelTest {
viewModel.uiState.test {
// Default item
awaitItem()
- paymentAvailability.tryEmit(productsUnavailable)
- serviceConnectionState.value =
+ paymentAvailabilityFlow.tryEmit(productsUnavailable)
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
val result = awaitItem().billingPaymentState
assertIs<PaymentState.NoPayment>(result)
@@ -200,8 +199,8 @@ class WelcomeViewModelTest {
fun testBillingProductsGenericErrorState() = runTest {
// Arrange
val paymentOtherError = PaymentAvailability.Error.Other(mockk())
- paymentAvailability.tryEmit(paymentOtherError)
- serviceConnectionState.value =
+ paymentAvailabilityFlow.tryEmit(paymentOtherError)
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -215,8 +214,8 @@ class WelcomeViewModelTest {
fun testBillingProductsBillingErrorState() = runTest {
// Arrange
val paymentBillingError = PaymentAvailability.Error.BillingUnavailable
- paymentAvailability.value = paymentBillingError
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = paymentBillingError
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -232,8 +231,8 @@ class WelcomeViewModelTest {
val mockProduct: PaymentProduct = mockk()
val expectedProductList = listOf(mockProduct)
val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct))
- paymentAvailability.value = productsAvailable
- serviceConnectionState.value =
+ paymentAvailabilityFlow.value = productsAvailable
+ serviceConnectionStateFlow.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
// Act, Assert
@@ -244,46 +243,6 @@ class WelcomeViewModelTest {
}
}
- @Test
- fun testBillingUserCancelled() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Cancelled
- purchaseResult.value = result
- serviceConnectionState.value =
- ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- every { result.toPaymentDialogData() } returns null
-
- // Act, Assert
- viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
- }
-
- @Test
- fun testBillingPurchaseSuccess() = runTest {
- // Arrange
- val result = PurchaseResult.Completed.Success
- val expectedData: PaymentDialogData = mockk()
- purchaseResult.value = result
- serviceConnectionState.value =
- ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- every { result.toPaymentDialogData() } returns expectedData
-
- // Act, Assert
- viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
- }
-
- @Test
- fun testStartBillingPayment() {
- // Arrange
- val mockProductId = ProductId("MOCK")
- val mockActivityProvider = mockk<() -> Activity>()
-
- // Act
- viewModel.startBillingPayment(mockProductId, mockActivityProvider)
-
- // Assert
- coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) }
- }
-
companion object {
private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
"net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"