summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson90@gmail.com>2023-12-14 16:40:25 +0100
committerAlbin <albin@mullvad.net>2023-12-14 16:54:21 +0100
commit435d437f344d484270c1ce55d9f65985287bfac8 (patch)
treea53801b0a90b04944938c1db9436cbe357208fe9
parentf33b1f76eac937b579ef589cc047da8f3421f630 (diff)
downloadmullvadvpn-435d437f344d484270c1ce55d9f65985287bfac8.tar.xz
mullvadvpn-435d437f344d484270c1ce55d9f65985287bfac8.zip
Migrate to Compose Destinations
-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
-rw-r--r--android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt7
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt2
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml4
-rw-r--r--android/lib/resource/src/main/res/values/styles.xml4
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt4
113 files changed, 4081 insertions, 3015 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"
diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt
index c95c8c9111..fe9564c45d 100644
--- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt
+++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt
@@ -8,7 +8,6 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.service.quicksettings.Tile
-import android.widget.Toast
object SdkUtils {
fun getSupportedPendingIntentFlags(): Int {
@@ -41,10 +40,4 @@ object SdkUtils {
} else {
@Suppress("DEPRECATION") getInstalledPackages(flags)
}
-
- fun showCopyToastIfNeeded(context: Context, message: String) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
- Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
- }
- }
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
index 008eb1ea7a..f003ee316b 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
@@ -1,7 +1,7 @@
package net.mullvad.mullvadvpn.model
import android.os.Parcelable
-import kotlinx.android.parcel.Parcelize
+import kotlinx.parcelize.Parcelize
sealed class AccountHistory : Parcelable {
@Parcelize data class Available(val accountToken: String) : AccountHistory()
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 0b690671ac..836f72cadc 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -171,7 +171,9 @@
<string name="vpn_permission_error_notification_title">VPN permission error</string>
<string name="vpn_permission_error_notification_message">Always-on VPN might be enabled for another app</string>
<string name="new_device_notification_title">NEW DEVICE CREATED</string>
- <string name="new_device_notification_message"><![CDATA[Welcome, this device is now called <b>%s</b>. For more details see the info button in Account.]]></string>
+ <string name="new_device_notification_message">
+ <![CDATA[Welcome, this device is now called <b>%s</b>. For more details see the info button in Account.]]>
+ </string>
<string name="agree_and_continue">Agree and continue</string>
<string name="privacy_disclaimer_title">Privacy</string>
<string name="privacy_disclaimer_body_first_paragraph">To make sure that you have the most secure version and to inform you of any issues with the current version that is running, the app performs version checks automatically. This sends the app version and Android system version to Mullvad servers. Mullvad keeps counters on number of used app versions and Android versions. The data is never stored or used in any way that can identify you.</string>
diff --git a/android/lib/resource/src/main/res/values/styles.xml b/android/lib/resource/src/main/res/values/styles.xml
index 69ee118cac..5c94a24ebf 100644
--- a/android/lib/resource/src/main/res/values/styles.xml
+++ b/android/lib/resource/src/main/res/values/styles.xml
@@ -1,8 +1,8 @@
<resources>
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
- <item name="android:navigationBarColor">@color/blue</item>
- <item name="android:statusBarColor">@color/blue</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@color/blue</item>
<item name="android:spotShadowAlpha">0</item>
<item name="actionBarSize">48dp</item>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index c9eb73e9ad..1f131b9aac 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -70,9 +70,11 @@ data class Dimensions(
val smallIconSize: Dp = 16.dp,
val smallPadding: Dp = 8.dp,
val spacingAboveButton: Dp = 22.dp,
+ val splashLogoSize: Dp = 120.dp,
+ val splashLogoTextHeight: Dp = 18.dp,
val successIconVerticalPadding: Dp = 26.dp,
val switchIconSize: Dp = 24.dp,
- val titleIconSize: Dp = 24.dp,
+ val titleIconSize: Dp = 48.dp,
val topBarHeight: Dp = 64.dp,
val verticalDividerPadding: Dp = 12.dp,
val verticalSpace: Dp = 20.dp,