From b3ae51ac85d6b3c51f0e1e81c8b2bb794a80b0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 6 Nov 2023 15:10:54 +0100 Subject: [PATCH] Migrate all navigation to compose navigation Remove old fragments Improve animations between destinations Fix glitch in animation Temporary fix for pending intent flags Fix status bar colors Fix transition for welcome screen Fix bug caused by triggering navigation too fast Fix accidentally not starting service Handle Change log Fix screen rotation Make settings use scaffold snackbar instead Migrate info dialogs to destinations Fix formatting Fix some failed tests Remove unused function Refactor out MTU dialog Migrate DnsDialog Fix Devicelist confirmation dialog Migrate ReportProblemNoEmail dialog Migrate custom WG port to dialog Fix voucher dialog and out of time navigation from connect Fix tests Update gradle lockfile Disable settings button while logging in Add nav graph Fix out of time navigation, transitions and multiple navigation calls. Fix xml formatting Fix test Add CVE supression Clean up build config Remove duplicate deps Fix test Fix VPNSettings UI tests Fix out of time bug! Remove notification limitation bump destinations version Fix setting onclick Move out getActivity Fix remarks Fix remarks Fix timeout Fix timeout clean up dns dialog Clarify OutOfTime usacase Handle billing error Overlay splash screen on no service connection Hide billing started dialog Handle service down with destination Update gradle verification file --- android/app/build.gradle.kts | 7 +- .../compose/dialog/CustomPortDialogTest.kt | 64 ++ .../compose/dialog/DnsDialogTest.kt | 125 +++ .../compose/dialog/MtuDialogTest.kt | 153 ++++ .../compose/dialog/PaymentDialogTest.kt | 64 ++ .../compose/screen/AccountScreenTest.kt | 111 +-- .../compose/screen/ChangelogDialogTest.kt | 17 +- .../compose/screen/ConnectScreenTest.kt | 55 +- .../compose/screen/OutOfTimeScreenTest.kt | 193 +---- .../compose/screen/RedeemVoucherDialogTest.kt | 13 +- .../screen/SelectLocationScreenTest.kt | 12 - .../compose/screen/SettingsScreenTest.kt | 4 - .../compose/screen/VpnSettingsScreenTest.kt | 597 +++---------- .../compose/screen/WelcomeScreenTest.kt | 231 +---- android/app/src/main/AndroidManifest.xml | 4 +- .../mullvadvpn/compose/cell/CustomPortCell.kt | 8 +- .../mullvadvpn/compose/cell/DnsCell.kt | 8 +- .../component/CopyableObfuscationView.kt | 25 +- .../compose/component/Scaffolding.kt | 30 +- .../mullvadvpn/compose/component/TopBar.kt | 17 +- .../compose/dialog/ChangelogDialog.kt | 63 +- .../dialog/ContentBlockersInfoDialog.kt | 8 +- .../compose/dialog/CustomDnsInfoDialog.kt | 11 +- .../compose/dialog/DeviceNameInfoDialog.kt | 8 +- .../mullvadvpn/compose/dialog/DnsDialog.kt | 136 +-- .../dialog/LocalNetworkSharingInfoDialog.kt | 11 +- .../compose/dialog/MalwareInfoDialog.kt | 14 +- .../mullvadvpn/compose/dialog/MtuDialog.kt | 42 +- .../compose/dialog/ObfuscationInfoDialog.kt | 14 +- .../dialog/QuantumResistanceInfoDialog.kt | 11 +- .../compose/dialog/RedeemVoucherDialog.kt | 18 + ...g.kt => RemoveDeviceConfirmationDialog.kt} | 27 +- .../dialog/ReportProblemNoEmailDialog.kt | 20 +- .../dialog/UdpOverTcpPortInfoDialog.kt | 12 +- ...Dialog.kt => WireguardCustomPortDialog.kt} | 68 +- .../compose/dialog/WireguardPortInfoDialog.kt | 29 +- .../compose/dialog/payment/PaymentDialog.kt | 44 +- .../payment/VerificationPendingDialog.kt | 9 + .../compose/screen/AccountScreen.kt | 155 ++-- .../compose/screen/ConnectScreen.kt | 106 ++- .../compose/screen/DeviceListScreen.kt | 71 +- .../compose/screen/DeviceRevokedScreen.kt | 36 +- .../mullvadvpn/compose/screen/LoginScreen.kt | 80 +- .../mullvadvpn/compose/screen/MullvadApp.kt | 125 +++ .../{LoadingScreen.kt => NoDaemonScreen.kt} | 40 +- .../compose/screen/OutOfTimeScreen.kt | 151 ++-- .../compose/screen/PrivacyDisclaimerScreen.kt | 51 +- .../screen/RedeemVoucherDialogScreen.kt | 32 - .../compose/screen/ReportProblemScreen.kt | 99 ++- .../compose/screen/SelectLocationScreen.kt | 243 +++--- .../compose/screen/SettingsScreen.kt | 44 +- .../mullvadvpn/compose/screen/SplashScreen.kt | 137 +++ .../compose/screen/SplitTunnelingScreen.kt | 29 + .../compose/screen/ViewLogsScreen.kt | 14 + .../compose/screen/VpnSettingsScreen.kt | 373 +++++---- .../compose/screen/WelcomeScreen.kt | 157 ++-- .../compose/state/DeviceListUiState.kt | 4 +- .../compose/state/OutOfTimeUiState.kt | 3 +- .../compose/state/VpnSettingsUiState.kt | 34 +- .../compose/state/WelcomeUiState.kt | 3 +- .../compose/test/ComposeTestTagConstants.kt | 4 + .../compose/textfield/DnsTextField.kt | 4 +- .../compose/transitions/NoTransition.kt | 25 + .../compose/transitions/SettingsTransition.kt | 23 + .../SlideInFromBottomTransition.kt | 23 + .../transitions/SlideInFromRightTransition.kt | 21 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 18 +- .../repository/ProblemReportRepository.kt | 10 +- .../repository/SettingsRepository.kt | 27 +- .../net/mullvad/mullvadvpn/ui/LoginState.kt | 8 - .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 264 +----- .../ui/extension/ContextExtensions.kt | 23 - .../mullvadvpn/ui/fragment/AccountFragment.kt | 56 -- .../mullvadvpn/ui/fragment/BaseFragment.kt | 71 -- .../mullvadvpn/ui/fragment/ConnectFragment.kt | 111 --- .../ui/fragment/DeviceListFragment.kt | 91 -- .../ui/fragment/DeviceRevokedFragment.kt | 42 - .../ui/fragment/FragmentArgumentConstant.kt | 3 - .../mullvadvpn/ui/fragment/LoadingFragment.kt | 30 - .../mullvadvpn/ui/fragment/LoginFragment.kt | 89 -- .../ui/fragment/OutOfTimeFragment.kt | 69 -- .../ui/fragment/PrivacyDisclaimerFragment.kt | 56 -- .../ui/fragment/ProblemReportFragment.kt | 57 -- .../fragment/RedeemVoucherDialogFragment.kt | 48 -- .../ui/fragment/SelectLocationFragment.kt | 44 - .../ui/fragment/SettingsFragment.kt | 72 -- .../ui/fragment/SplitTunnelingFragment.kt | 44 - .../ui/fragment/ViewLogsFragment.kt | 36 - .../ui/fragment/VpnSettingsFragment.kt | 69 -- .../mullvadvpn/ui/fragment/WelcomeFragment.kt | 68 -- .../ServiceConnectionManager.kt | 12 +- .../mullvadvpn/usecase/OutOfTimeUseCase.kt | 62 ++ .../mullvadvpn/util/ContextExtensions.kt | 13 + .../net/mullvad/mullvadvpn/util/FlowUtils.kt | 31 +- .../util/PortConstraintExtensions.kt | 6 +- .../mullvadvpn/viewmodel/AccountViewModel.kt | 26 +- .../viewmodel/ChangelogViewModel.kt | 49 +- .../mullvadvpn/viewmodel/ConnectViewModel.kt | 22 +- .../viewmodel/DeviceListViewModel.kt | 88 +- .../viewmodel/DnsDialogViewModel.kt | 170 ++++ .../mullvadvpn/viewmodel/LoginViewModel.kt | 24 +- .../viewmodel/MtuDialogViewModel.kt | 38 + .../viewmodel/OutOfTimeViewModel.kt | 13 +- .../mullvadvpn/viewmodel/PaymentViewModel.kt | 46 + .../viewmodel/PrivacyDisclaimerViewModel.kt | 17 +- .../viewmodel/ReportProblemViewModel.kt | 36 +- .../viewmodel/SelectLocationViewModel.kt | 18 +- .../viewmodel/ServiceConnectionViewModel.kt | 32 + .../mullvadvpn/viewmodel/SettingsViewModel.kt | 11 - .../mullvadvpn/viewmodel/SplashViewModel.kt | 110 +++ .../viewmodel/VoucherDialogViewModel.kt | 2 +- .../viewmodel/VpnSettingsViewModel.kt | 270 ++---- .../viewmodel/VpnSettingsViewModelState.kt | 80 +- .../mullvadvpn/viewmodel/WelcomeViewModel.kt | 14 +- .../src/main/res/layout/fragment_compose.xml | 9 - android/app/src/main/res/layout/main.xml | 10 - .../usecase/OutOfTimeUseCaseTest.kt | 100 +++ .../viewmodel/AccountViewModelTest.kt | 26 - .../viewmodel/ChangelogViewModelTest.kt | 67 +- .../viewmodel/ConnectViewModelTest.kt | 14 +- .../viewmodel/LoginViewModelTest.kt | 4 + .../viewmodel/OutOfTimeViewModelTest.kt | 43 - .../viewmodel/PaymentViewModelTest.kt | 70 ++ .../viewmodel/ReportProblemModelTest.kt | 77 +- .../viewmodel/SelectLocationViewModelTest.kt | 4 +- .../viewmodel/VpnSettingsViewModelTest.kt | 27 +- .../viewmodel/WelcomeViewModelTest.kt | 83 +- .../buildSrc/src/main/kotlin/Dependencies.kt | 3 + android/buildSrc/src/main/kotlin/Versions.kt | 11 +- .../config/dependency-check-suppression.xml | 9 + android/docs/diagrams/nav_graph.dot | 48 ++ android/docs/diagrams/nav_graph.svg | 216 +++++ android/docs/diagrams/update_graph.sh | 5 + android/gradle/verification-metadata.xml | 791 ++++++++++++------ .../mullvadvpn/lib/common/util/SdkUtils.kt | 7 - .../mullvadvpn/model/AccountHistory.kt | 2 +- .../resource/src/main/res/anim/do_nothing.xml | 6 - .../res/anim/fragment_enter_from_bottom.xml | 6 - .../res/anim/fragment_enter_from_right.xml | 6 - .../main/res/anim/fragment_exit_to_bottom.xml | 6 - .../main/res/anim/fragment_exit_to_left.xml | 6 - .../main/res/anim/fragment_exit_to_right.xml | 6 - .../anim/fragment_half_enter_from_left.xml | 6 - .../res/anim/fragment_half_exit_to_left.xml | 6 - .../src/main/res/drawable/icon_fail.xml | 18 +- .../src/main/res/drawable/icon_success.xml | 18 +- .../resource/src/main/res/values/strings.xml | 4 +- .../resource/src/main/res/values/styles.xml | 4 +- 148 files changed, 4345 insertions(+), 4339 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt create mode 100644 android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt create mode 100644 android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt create mode 100644 android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt rename android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/{DeviceRemovalDialog.kt => RemoveDeviceConfirmationDialog.kt} (73%) rename android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/{CustomPortDialog.kt => WireguardCustomPortDialog.kt} (68%) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt rename android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/{LoadingScreen.kt => NoDaemonScreen.kt} (68%) delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/NoTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt delete mode 100644 android/app/src/main/res/layout/fragment_compose.xml delete mode 100644 android/app/src/main/res/layout/main.xml create mode 100644 android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt create mode 100644 android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt create mode 100644 android/docs/diagrams/nav_graph.dot create mode 100644 android/docs/diagrams/nav_graph.svg create mode 100755 android/docs/diagrams/update_graph.sh delete mode 100644 android/lib/resource/src/main/res/anim/do_nothing.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml delete mode 100644 android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a3035f0a12da..1bc74da6f9a7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id(Dependencies.Plugin.playPublisherId) id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.ksp) version Versions.Plugin.ksp } val repoRootPath = rootProject.projectDir.absoluteFile.parentFile.absolutePath @@ -181,8 +182,7 @@ android { val enableInAppVersionNotifications = gradleLocalProperties(rootProject.projectDir) - .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") - ?: "true" + .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") ?: "true" buildConfigField( "boolean", @@ -337,6 +337,9 @@ dependencies { implementation(Dependencies.Compose.uiController) implementation(Dependencies.Compose.ui) implementation(Dependencies.Compose.uiUtil) + implementation(Dependencies.Compose.destinations) + ksp(Dependencies.Compose.destinationsKsp) + implementation(Dependencies.jodaTime) implementation(Dependencies.Koin.core) implementation(Dependencies.Koin.android) 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 000000000000..43e385b65d2a --- /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 = 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 000000000000..c5fa6db4eab7 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt @@ -0,0 +1,125 @@ +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 io.mockk.MockKAnnotations +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class DnsDialogTest { + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + 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 = "300.300.300.300", + 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." + } +} 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 000000000000..38a3bd170da3 --- /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 000000000000..63d822925574 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt @@ -0,0 +1,64 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import io.mockk.MockKAnnotations +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.Before +import org.junit.Rule +import org.junit.Test + +class PaymentDialogTest { + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + @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 e997ae29e4e3..3b42cc1c3b10 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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onLogoutClick = mockedClickHandler ) } @@ -138,80 +131,14 @@ class AccountScreenTest { verify { mockedClickHandler.invoke() } } - @Test - fun testShowPurchaseCompleteDialog() { - // Arrange - composeTestRule.setContentWithTheme { - AccountScreen( - showSitePayment = true, - uiState = - AccountUiState.default() - .copy( - paymentDialogData = - PurchaseResult.Completed.Success.toPaymentDialogData() - ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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 ab8f2b15123e..5410c627a988 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 @@ -31,15 +30,15 @@ class ChangelogDialogTest { @Test 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 56894addeaa3..cd25c8ce0b4b 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().asSharedFlow() ) } @@ -88,7 +84,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -124,7 +119,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -158,7 +152,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -191,7 +184,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -225,7 +217,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -259,7 +250,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -298,7 +288,6 @@ class ConnectScreenTest { ErrorState(ErrorStateCause.StartTunnelError, true) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -338,7 +327,6 @@ class ConnectScreenTest { ErrorState(ErrorStateCause.StartTunnelError, false) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -372,7 +360,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -408,7 +395,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -444,7 +430,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onSwitchLocationClick = mockedClickHandler ) } @@ -477,7 +462,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onDisconnectClick = mockedClickHandler ) } @@ -510,7 +494,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onReconnectClick = mockedClickHandler ) } @@ -542,7 +525,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onConnectClick = mockedClickHandler ) } @@ -574,7 +556,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onCancelClick = mockedClickHandler ) } @@ -607,7 +588,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onToggleTunnelInfo = mockedClickHandler ) } @@ -647,7 +627,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -686,7 +665,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UpdateAvailable(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -723,7 +701,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -757,7 +734,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -796,7 +772,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -829,7 +804,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), - uiSideEffect = MutableSharedFlow().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/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt index 28e2519c8142..d43a0931a1d6 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 = {} ) } @@ -86,42 +72,17 @@ class OutOfTimeScreenTest { composeTestRule.apply { onNodeWithText("Congrats!").assertDoesNotExist() } } - @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().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().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().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().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().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 c07cb1aa6b32..5a51a8f88538 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 3b5da50d3374..4fd440accd79 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().asSharedFlow() ) } @@ -58,8 +54,6 @@ class SelectLocationScreenTest { countries = DUMMY_RELAY_COUNTRIES, selectedRelay = null ), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -95,8 +89,6 @@ class SelectLocationScreenTest { countries = updatedDummyList, selectedRelay = updatedDummyList[0].cities[0].relays[0] ), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -119,8 +111,6 @@ class SelectLocationScreenTest { SelectLocationScreen( uiState = SelectLocationUiState.ShowData(countries = emptyList(), selectedRelay = null), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onSearchTermInput = mockedSearchTermInput ) } @@ -141,8 +131,6 @@ class SelectLocationScreenTest { composeTestRule.setContentWithTheme { SelectLocationScreen( uiState = SelectLocationUiState.NoSearchResultFound(searchTerm = mockSearchString), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().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 576660551e6a..e15ed012d6a3 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().asSharedFlow() ) } // Assert @@ -47,7 +44,6 @@ class SettingsScreenTest { SettingsScreen( uiState = SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = true), - enterTransitionEndAction = MutableSharedFlow().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 9b6dd9e492ba..1ca2b3e1f7b6 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().asSharedFlow() ) } @@ -74,7 +65,6 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(mtu = VALID_DUMMY_MTU_VALUE), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() ) } @@ -86,166 +76,6 @@ class VpnSettingsScreenTest { composeTestRule.onNodeWithText(VALID_DUMMY_MTU_VALUE).assertExists() } - @Test - fun testMtuClick() { - // Arrange - val mockedClickHandler: () -> Unit = mockk(relaxed = true) - composeTestRule.setContentWithTheme { - VpnSettingsScreen( - uiState = VpnSettingsUiState.createDefault(), - onMtuCellClick = mockedClickHandler, - toastMessagesSharedFlow = MutableSharedFlow().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().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().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().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().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().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().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().asSharedFlow() - ) - } - - // Assert - composeTestRule.onNodeWithText("Cancel").performClick() - - // Assert - verify { mockedClickHandler.invoke() } - } - @Test fun testCustomDnsAddressesAndAddButtonVisibleWhenCustomDnsEnabled() { // Arrange @@ -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().asSharedFlow() ) } @@ -285,7 +113,6 @@ class VpnSettingsScreenTest { isCustomDnsEnabled = false, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, false)) ), - toastMessagesSharedFlow = MutableSharedFlow().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().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().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().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().asSharedFlow() ) } @@ -378,221 +198,6 @@ 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().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().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().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().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().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().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().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().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().asSharedFlow() - ) - } - - // Assert - composeTestRule.onNodeWithText("Submit").assertIsNotEnabled() - } - @Test fun testShowSelectedTunnelQuantumOption() { // Arrange @@ -600,7 +205,6 @@ class VpnSettingsScreenTest { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(quantumResistant = QuantumResistantState.On), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() ) } composeTestRule @@ -624,8 +228,7 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( quantumResistant = QuantumResistantState.Auto, ), - onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener ) } composeTestRule @@ -641,23 +244,6 @@ class VpnSettingsScreenTest { } } - @Test - fun testShowTunnelQuantumInfo() { - // Arrange - composeTestRule.setContentWithTheme { - VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.QuantumResistanceInfo - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() - ) - } - - // Assert - composeTestRule.onNodeWithText("Got it!").assertExists() - } - @Test fun testShowWireguardPortOptions() { // Arrange @@ -667,7 +253,6 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(53)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() ) } @@ -698,8 +283,7 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(53)) ), - onWireguardPortSelected = mockSelectWireguardPortSelectionListener, - toastMessagesSharedFlow = MutableSharedFlow().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().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) -> 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().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().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().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) -> 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().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) -> 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().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().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 a54c41c20d71..e62b1a399b15 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 = {} ) } @@ -113,68 +100,20 @@ class WelcomeScreenTest { composeTestRule.apply { onNodeWithText(expectedAccountNumber).assertExists() } } - @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 = {} ) } @@ -211,100 +148,19 @@ class WelcomeScreenTest { verify(exactly = 1) { mockClickListener.invoke() } } - @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().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().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().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().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().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/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index feba1dabf859..d7fa4b026313 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -34,8 +34,8 @@ android:exported="true" android:launchMode="singleTask" android:configChanges="orientation|screenSize|screenLayout" - android:screenOrientation="locked" - android:windowSoftInputMode="adjustPan"> + android:screenOrientation="fullUser" + android:windowSoftInputMode="adjustResize"> 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 8219aa998431..cd5a08edbfb4 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 4d6fb89834bb..2a0043842a64 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/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index a4352147329a..12e657d3ed38 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 @@ -11,24 +11,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter -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) { @@ -45,19 +46,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 ce8507db6403..9a35df1ad3aa 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 babd89271c33..73bec5f14f1c 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 9ce21c6bac1c..755c13056830 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, version: String, onDismiss: () -> Unit) { +fun Changelog(navController: NavController, changeLog: ChangeLog) { + val viewModel = koinViewModel() + + 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, 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 29a57ed33175..145208ce165e 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 cf9233ec94ce..f58768d0c6ab 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 39e82bc57dd7..0e1c315959a4 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 527fcf8738f9..7de79207e19d 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, + index: Int?, + initialValue: String?, +) { + val viewModel = + koinViewModel(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 983d0c1e04be..ebe46b6050dd 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 378e95c98e61..1f627be040fa 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 bc28169bb23d..d0d8da8b5725 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() + + 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 f54eabdbafb5..cf4db26e2e9b 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 3a20e9c80576..e7773ed0a382 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 1c48a8a64aa8..15d8e9f3c763 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) { + val vm = koinViewModel() + 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/DeviceRemovalDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt similarity index 73% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt index d99f9e62b7ee..97fff0ff2716 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -18,27 +18,34 @@ 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 PreviewShowDeviceRemovalDialog() { - DeviceRemovalDialog( - onDismiss = {}, - onConfirm = {}, - device = Device("test", "test", byteArrayOf(), "test") - ) +private fun PreviewShowDefalseviceRemovalDialog() { + AppTheme { + RemoveDeviceConfirmationDialog( + EmptyResultBackNavigator(), + device = Device("test", "test", byteArrayOf(), "test") + ) + } } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun DeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device: Device) { +fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator, device: Device) { AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { navigator.navigateBack() }, title = { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -62,14 +69,14 @@ fun DeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device: De }, dismissButton = { NegativeButton( - onClick = onConfirm, + onClick = { navigator.navigateBack(result = device.id) }, text = stringResource(id = R.string.confirm_removal) ) }, confirmButton = { PrimaryButton( modifier = Modifier.focusRequester(FocusRequester()), - onClick = onDismiss, + onClick = { navigator.navigateBack() }, text = stringResource(id = R.string.back) ) }, 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 1e2da9f95100..f053cd74f6c5 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) { 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 f81412799063..1c5c4ccef636 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/CustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt similarity index 68% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt index a30e525e6d59..9b2f495f4d4f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt @@ -1,5 +1,6 @@ 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 @@ -15,6 +16,11 @@ 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 @@ -29,29 +35,46 @@ import net.mullvad.mullvadvpn.util.isPortInValidRanges @Preview @Composable -private fun PreviewCustomPortDialog() { +private fun PreviewWireguardCustomPortDialog() { AppTheme { - CustomPortDialog( - onSave = {}, - onReset = {}, - customPort = "", - allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), - showReset = true, - onDismissRequest = {} + WireguardCustomPortDialog( + WireguardCustomPortNavArgs( + customPort = null, + allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), + ), + EmptyResultBackNavigator() ) } } +@Parcelize +data class WireguardCustomPortNavArgs( + val customPort: Int?, + val allowedPortRanges: List, +) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun WireguardCustomPortDialog( + navArg: WireguardCustomPortNavArgs, + backNavigator: ResultBackNavigator, +) { + WireguardCustomPortDialog( + initialPort = navArg.customPort, + allowedPortRanges = navArg.allowedPortRanges, + onSave = { port -> backNavigator.navigateBack(port) }, + onDismiss = backNavigator::navigateBack + ) +} + @Composable -fun CustomPortDialog( - customPort: String, +fun WireguardCustomPortDialog( + initialPort: Int?, allowedPortRanges: List, - showReset: Boolean, - onSave: (customPortString: String) -> Unit, - onReset: () -> Unit, - onDismissRequest: () -> Unit + onSave: (Int?) -> Unit, + onDismiss: () -> Unit ) { - val port = remember { mutableStateOf(customPort) } + val port = remember { mutableStateOf(initialPort?.toString() ?: "") } AlertDialog( title = { @@ -64,21 +87,18 @@ fun CustomPortDialog( Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { PrimaryButton( text = stringResource(id = R.string.custom_port_dialog_submit), - onClick = { onSave(port.value) }, + onClick = { onSave(port.value.toInt()) }, isEnabled = port.value.isNotEmpty() && allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0) ) - if (showReset) { + if (initialPort != null) { NegativeButton( text = stringResource(R.string.custom_port_dialog_remove), - onClick = onReset + onClick = { onSave(null) } ) } - PrimaryButton( - text = stringResource(id = R.string.cancel), - onClick = onDismissRequest - ) + PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss) } }, text = { @@ -90,7 +110,7 @@ fun CustomPortDialog( input.isNotEmpty() && allowedPortRanges.isPortInValidRanges(input.toIntOrNull() ?: 0) ) { - onSave(input) + onSave(input.toIntOrNull()) } }, onValueChanged = { input -> port.value = input }, @@ -114,6 +134,6 @@ fun CustomPortDialog( }, containerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.onBackground, - onDismissRequest = onDismissRequest + 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 58ddb00e2033..a3329b1248b2 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) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun WireguardPortInfoDialog(portRanges: List, 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 7e94b7455efe..7de033e9631b 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,40 @@ private fun PreviewPaymentDialogPaymentAvailabilityError() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator) { + val vm = koinViewModel() + 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) { + return + } + + 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 112afeebf5c0..7d49a133f359 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 fecd23406a8f..b9b183687282 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.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().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun Account( + navigator: DestinationsNavigator, + playPaymentResultRecipient: ResultRecipient +) { + val vm = koinViewModel() + 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, - enterTransitionEndAction: SharedFlow, + 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 7528b46e425e..037ec9d264a0 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.NoTransition 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().asSharedFlow() ) } } +@Destination(style = NoTransition::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, - 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 1617c1fb7a5a..96f2894a23f2 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 +) { + val viewModel = koinViewModel() + 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 5ec6b9a64ba6..11e929c905e9 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() + + 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/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index e7d6e40a60c6..d728813dc151 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.* @@ -66,6 +75,9 @@ 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 @@ -99,9 +111,63 @@ private fun PreviewLoginSuccess() { AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } } -@OptIn(ExperimentalComposeUiApi::class) +@Destination +@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 = {}, @@ -110,13 +176,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 000000000000..bddf5b661747 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -0,0 +1,125 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.navigate +import com.ramcosta.composedestinations.navigation.popBackStack +import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.utils.destination +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.compose.NavGraphs +import net.mullvad.mullvadvpn.compose.appCurrentDestinationFlow +import net.mullvad.mullvadvpn.compose.destinations.ChangelogDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.Destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.destinations.SplashDestination +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.ServiceConnectionViewModel +import net.mullvad.mullvadvpn.viewmodel.ServiceState +import org.koin.androidx.compose.koinViewModel + +private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) +private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MullvadApp() { + val engine = rememberNavHostEngine() + val navController: NavHostController = engine.rememberNavController() + + val serviceVm = koinViewModel() + + DestinationsNavHost( + modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), + engine = engine, + navController = navController, + navGraph = NavGraphs.root + ) + + // Globally handle daemon dropped connection with NoDaemonScreen + LaunchedEffect(Unit) { + combine( + serviceVm.uiState + // Wait for the first connected state + .dropWhile { it !is ServiceState.Connected }, + navController.appCurrentDestinationFlow, + ) { serviceState, destination -> + val backstackContainsNoDaemon = + navController.backStackContains(NoDaemonScreenDestination) + // If we are have NoDaemonScreen on backstack and received a connected state, pop it + if (backstackContainsNoDaemon && serviceState == ServiceState.Connected) { + DaemonNavigation.RemoveNoDaemonScreen + } + // If we are not connected to and expect to have a service connection, show + // NoDaemonScreen. + else if ( + backstackContainsNoDaemon || + destination.shouldHaveServiceConnection() && + serviceState == ServiceState.Disconnected + ) { + DaemonNavigation.ShowNoDaemonScreen + } else { + // Else, we don't have noDaemonScreen on backstack and don't do anything + null + } + } + // We don't care about null + .filterNotNull() + // Only care about changes + .distinctUntilChanged() + .collect { + when (it) { + DaemonNavigation.ShowNoDaemonScreen -> + navController.navigate(NoDaemonScreenDestination) + DaemonNavigation.RemoveNoDaemonScreen -> + navController.popBackStack(NoDaemonScreenDestination, true) + } + } + } + + // Globally show the changelog + val changeLogsViewModel = koinViewModel() + 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) + } + } +} + +private fun Destination.shouldHaveServiceConnection() = this !in noServiceDestinations + +sealed interface DaemonNavigation { + data object ShowNoDaemonScreen : DaemonNavigation + + data object RemoveNoDaemonScreen : DaemonNavigation +} + +fun NavController.backStackContains(destination: Destination) = + try { + getBackStackEntry(destination.route) + true + } catch (e: IllegalArgumentException) { + false + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt similarity index 68% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt index d27b4196aa21..b864d65ace7c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt @@ -1,5 +1,6 @@ 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 @@ -12,34 +13,61 @@ 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.platform.LocalContext 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 androidx.core.app.ActivityCompat.finishAffinity +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.flow.first import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination 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.ServiceConnectionViewModel +import net.mullvad.mullvadvpn.viewmodel.ServiceState +import org.koin.androidx.compose.koinViewModel @Preview @Composable -private fun PreviewLoadingScreen() { - AppTheme { LoadingScreen() } +private fun PreviewNoDaemonScreen() { + AppTheme { NoDaemonScreen({}) } } +// Set this as the start destination of the default nav graph +@Destination @Composable -fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { +fun NoDaemonScreen(navigator: DestinationsNavigator) { + + val serviceVm = koinViewModel() + LaunchedEffect(Unit) { + serviceVm.uiState.first { it is ServiceState.Connected } + navigator.navigateUp() + } + + 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, - statusBarColor = backgroundColor, - navigationBarColor = backgroundColor, - onSettingsClicked = onSettingsCogClicked, + onSettingsClicked = onNavigateToSettings, onAccountClicked = null, isIconAndLogoVisible = false, content = { 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 a7fd6bae2fa0..18925d0524dd 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.NoTransition 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().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().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().asSharedFlow() ) } } +@Destination(style = NoTransition::class) @Composable -fun OutOfTimeScreen( - showSitePayment: Boolean, - uiState: OutOfTimeUiState, - uiSideEffect: SharedFlow, - 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, + resultRecipient: ResultRecipient, + playPaymentResultRecipient: ResultRecipient ) { - val context = LocalContext.current + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + resultRecipient.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 -> + 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 02250c36632f..3dab053ea3b9 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,23 @@ 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.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 +55,41 @@ private fun PreviewPrivacyDisclaimerScreen() { AppTheme { PrivacyDisclaimerScreen({}, {}) } } +@Destination +@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/RedeemVoucherDialogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt deleted file mode 100644 index 1db18b01a31b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.mullvad.mullvadvpn.compose.screen - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog -import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState -import net.mullvad.mullvadvpn.lib.theme.AppTheme - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) -@Composable -private fun PreviewRedeemVoucherDialogScreen() { - AppTheme { - RedeemVoucherDialogScreen( - uiState = VoucherDialogUiState.INITIAL, - onVoucherInputChange = {}, - onRedeem = {}, - onDismiss = {} - ) - } -} - -@Composable -internal fun RedeemVoucherDialogScreen( - uiState: VoucherDialogUiState, - onVoucherInputChange: (String) -> Unit = {}, - onRedeem: (voucherCode: String) -> Unit, - onDismiss: (isTimeAdded: Boolean) -> Unit -) { - RedeemVoucherDialog(uiState, onVoucherInputChange, onRedeem, onDismiss) -} 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 0621c7ebcd37..af1c8e2db9cb 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 +) { + val vm = koinViewModel() + 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 c09a0b986a61..ad920c4852ff 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 @@ -7,7 +7,6 @@ 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 @@ -16,9 +15,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -36,9 +37,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp 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.RelayLocationCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge @@ -49,11 +49,15 @@ 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.SlideInFromBottomTransition 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 @@ -66,136 +70,149 @@ private fun PreviewSelectLocationScreen() { AppTheme { SelectLocationScreen( uiState = state, - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun SelectLocation(navigator: DestinationsNavigator) { + val vm = koinViewModel() + 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, + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable fun SelectLocationScreen( uiState: SelectLocationUiState, - uiCloseAction: SharedFlow, - enterTransitionEndAction: SharedFlow, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> 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( - horizontal = Dimens.selectLocationTitlePadding, - vertical = Dimens.selectLocationTitlePadding - ) - .fillMaxWidth() - ) { - Image( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, + Scaffold { + Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { + Row( modifier = - Modifier.focusRequester(backFocus) - .focusProperties { next = listFocus } - .focusProperties { - down = listFocus - right = searchBarFocus - } - .size(Dimens.titleIconSize) - .rotate(270f) - .clickable { onBackClick() } - ) - 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.headlineSmall.copy(fontSize = 20.sp), - color = MaterialTheme.colorScheme.onPrimary - ) - } - 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) + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding, + vertical = Dimens.selectLocationTitlePadding ) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + modifier = + Modifier.focusRequester(backFocus) + .focusProperties { next = listFocus } + .focusProperties { + down = listFocus + right = searchBarFocus + } + .size(Dimens.titleIconSize) + .rotate(270f) + .clickable { onBackClick() } + ) + 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.headlineSmall.copy(fontSize = 20.sp), + color = MaterialTheme.colorScheme.onPrimary + ) + } + 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) + ) + } } - } - is SelectLocationUiState.ShowData -> { - 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() - ) + is SelectLocationUiState.ShowData -> { + 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() + ) + } } - } - is SelectLocationUiState.NoSearchResultFound -> { - 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) - Text( - text = - buildAnnotatedString { - append(firstRow) - appendLine() - append( + is SelectLocationUiState.NoSearchResultFound -> { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( textResource( - id = R.string.select_location_empty_text_second_row - ) + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT ) - }, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center - ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + Text( + text = + buildAnnotatedString { + append(firstRow) + appendLine() + append( + textResource( + id = R.string.select_location_empty_text_second_row + ) + ) + }, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + } } } } 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 b092ed981b5f..d057da60f801 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() + 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, 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 000000000000..8a7c7176521f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -0,0 +1,137 @@ +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 androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.lib.theme.AppTheme +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 +@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(120.dp) + ) + Image( + painter = painterResource(id = R.drawable.logo_text), + contentDescription = "", + alpha = 0.6f, + modifier = Modifier.padding(top = 12.dp).height(18.dp) + ) + Text( + text = stringResource(id = R.string.connecting_to_daemon), + fontSize = 13.sp, + color = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDescription) + .compositeOver(backgroundColor), + modifier = Modifier.padding(top = 12.dp) + ) + } + } + } + ) +} 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 a1f9bd8a97fd..339673949178 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() + 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 7ff8aa11aa03..ef4e58cda1b0 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() + 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 7290b9600fd3..286d7697b489 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().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, - onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {}, - onObfuscationInfoClick: () -> Unit = {}, - onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {}, - onQuantumResistanceInfoClicked: () -> Unit = {}, - onWireguardPortSelected: (port: Constraint) -> Unit = {}, - onWireguardPortInfoClicked: () -> Unit = {}, - onShowCustomPortDialog: () -> Unit = {}, - onCancelCustomPortDialogClick: () -> Unit = {}, - onCloseCustomPortDialog: () -> Unit = {} +fun VpnSettings( + navigator: DestinationsNavigator, + dnsDialogResult: ResultRecipient, + customWgPortResult: ResultRecipient ) { - val savedCustomPort = rememberSaveable { mutableStateOf>(Constraint.Any()) } + val vm = koinViewModel() + 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.onDnsDialogDismiss() + } + 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::onToggleDnsClick, + 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) -> 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) -> 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 d26e8c826539..b5bacde95aef 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,8 +18,8 @@ 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 @@ -30,21 +29,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.NoTransition 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 +64,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 +83,98 @@ private fun PreviewWelcomeScreen() { ) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - openConnectScreen = {}, - onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onPurchaseBillingProductClick = { _ -> }, + navigateToDeviceInfoDialog = {}, + navigateToVerificationPendingDialog = {} ) } } +@Destination(style = NoTransition::class) @Composable -fun WelcomeScreen( - showSitePayment: Boolean, - uiState: WelcomeUiState, - uiSideEffect: SharedFlow, - 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, + playPaymentResultRecipient: ResultRecipient ) { + val vm = koinViewModel() + 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)) + }, + 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 +185,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 +208,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 +229,7 @@ fun WelcomeScreen( private fun WelcomeInfo( snackbarHostState: SnackbarHostState, uiState: WelcomeUiState, - showSitePayment: Boolean + navigateToDeviceInfoDialog: () -> Unit ) { Column { Text( @@ -217,13 +260,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 +312,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 +331,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 +341,6 @@ fun DeviceNameRow(deviceName: String?) { tint = MullvadWhite ) } - if (showDeviceNameDialog) { - DeviceNameInfoDialog { showDeviceNameDialog = false } - } } } @@ -311,7 +350,7 @@ 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 @@ -326,7 +365,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 e22aaffde2e0..e539dbafc6bb 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, 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 0491f80ea0de..54fd414f866a 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 e78d2e9f436f..5525dee8ce88 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, val contentBlockersOptions: DefaultDnsOptions, - val isAllowLanEnabled: Boolean, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint, + val customWireguardPort: Constraint?, val availablePortRanges: List, - val dialog: VpnSettingsDialog? ) { companion object { @@ -32,12 +30,11 @@ data class VpnSettingsUiState( isCustomDnsEnabled: Boolean = false, customDnsItems: List = emptyList(), contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(), - isAllowLanEnabled: Boolean = false, selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off, quantumResistant: QuantumResistantState = QuantumResistantState.Off, selectedWireguardPort: Constraint = Constraint.Any(), + customWireguardPort: Constraint.Only? = null, availablePortRanges: List = 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 = emptyList()) : - VpnSettingsDialog - - data class CustomPort(val availablePortRanges: List = 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 bd1c19e9c9e3..e2673a0ddf38 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 e3cb4faa5b32..ff5bbf43cc43 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 d7aec9e417c7..388bec98bf39 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/NoTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/NoTransition.kt new file mode 100644 index 000000000000..487ec90ea0ac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/NoTransition.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object NoTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + EnterTransition.None + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope.exitTransition() = + fadeOut(snap(700)) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + EnterTransition.None + + override fun AnimatedContentTransitionScope.popExitTransition() = + ExitTransition.None +} 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 000000000000..8f59476e5068 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +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 + +object SettingsTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + slideOutHorizontally(targetOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + slideInHorizontally(initialOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.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 000000000000..6b404c2b9248 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromBottomTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition(): + EnterTransition { + return slideInVertically(initialOffsetY = { it }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = null + + override fun AnimatedContentTransitionScope.popExitTransition(): + ExitTransition { + return 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 000000000000..9a18a2e5de52 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromRightTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInHorizontally(initialOffsetX = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + slideOutHorizontally(targetOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + slideInHorizontally(initialOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popExitTransition() = + slideOutHorizontally(targetOffsetX = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index b39d16b0aaa5..56d8752f574f 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 @@ -26,6 +26,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase 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 @@ -39,12 +40,17 @@ 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.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel +import net.mullvad.mullvadvpn.viewmodel.ServiceConnectionViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel @@ -98,6 +104,7 @@ val uiModule = module { single { NewDeviceNotificationUseCase(get()) } single { PortRangeUseCase(get()) } single { RelayListUseCase(get(), get()) } + single { OutOfTimeUseCase(get(), get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -124,19 +131,26 @@ 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 { LoginViewModel(get(), get(), get()) } + viewModel { MtuDialogViewModel(get()) } + viewModel { parameters -> + DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull()) + } viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } - viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get()) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { PaymentViewModel(get()) } + viewModel { ServiceConnectionViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" 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 3086ee9b802c..6c5387a5b1af 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 = _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 ac9637c68342..557eb7f72da2 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 @@ -32,7 +32,7 @@ class SettingsRepository( callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) } .onStart { serviceConnectionManager.settingsListener()?.settingsNotifier?.latestEvent } - .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), null) + .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, null) fun setDnsOptions( isCustomDnsEnabled: Boolean, @@ -51,6 +51,31 @@ class SettingsRepository( ) } + fun setDnsState( + state: DnsState, + ) { + updateDnsSettings { it.copy(state = state) } + } + + fun updateCustomDnsList(update: (List) -> List) { + 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?) { serviceConnectionManager.settingsListener()?.wireguardMtu = value } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt deleted file mode 100644 index cece17826792..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -enum class LoginState { - Initial, - InProgress, - Success, - Failure, -} 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 f299b8c956bd..c5b1add3a375 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,86 +2,47 @@ 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.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 org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -import org.koin.dsl.bind -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 - 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() @@ -90,39 +51,16 @@ open class MainActivity : FragmentActivity() { changelogViewModel = get() } - requestedOrientation = - if (deviceIsTv) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - super.onCreate(savedInstanceState) - setContentView(R.layout.main) + setContent { AppTheme { MullvadApp() } } } - override fun onStart() { - Log.d("mullvad", "Starting main activity") - super.onStart() - - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - } else { - openPrivacyDisclaimerFragment() - } - } - - fun initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - deviceStateJob = launchDeviceStateHandler() + fun initializeStateHandlerAndServiceConnection() { checkForNotificationPermission() serviceConnectionManager.bind( vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = apiEndpointConfiguration + apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() ) } @@ -130,6 +68,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,8 +83,6 @@ 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() { @@ -146,88 +90,6 @@ open class MainActivity : FragmentActivity() { 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() - } - } - - 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(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) @@ -235,97 +97,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/extension/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt deleted file mode 100644 index e2e2f5c44c63..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package net.mullvad.mullvadvpn.ui.extension - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.ui.MainActivity - -fun Fragment.requireMainActivity(): MainActivity { - return if (this.activity is MainActivity) { - this.activity as MainActivity - } else { - throw IllegalStateException( - "Fragment $this not attached to ${MainActivity::class.simpleName}." - ) - } -} - -fun Context.copyToClipboard(content: String, clipboardLabel: String) { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText(clipboardLabel, content) - clipboard.setPrimaryClip(clipData) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt deleted file mode 100644 index 5225368dacbb..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.AccountScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.AccountViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class AccountFragment : BaseFragment() { - private val vm by viewModel() - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - AccountScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - enterTransitionEndAction = vm.enterTransitionEndAction, - onRedeemVoucherClick = { openRedeemVoucherFragment() }, - onManageAccountClick = vm::onManageAccountClick, - onLogoutClick = vm::onLogoutClick, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() } - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment().show(transaction, null) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt deleted file mode 100644 index 99b9b42f0982..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.annotation.LayoutRes -import androidx.core.view.ViewCompat -import androidx.fragment.app.Fragment -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.transitionFinished - -abstract class BaseFragment : Fragment { - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - protected var transitionFinishedFlow: Flow = emptyFlow() - private set - - override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { - val zAdjustment = - if (animationsToAdjustZorder.contains(nextAnim)) { - 1f - } else { - 0f - } - ViewCompat.setTranslationZ(requireView(), zAdjustment) - val anim = - if (nextAnim != 0 && enter) { - AnimationUtils.loadAnimation(context, nextAnim)?.apply { - transitionFinishedFlow = transitionFinished() - } - } else { - super.onCreateAnimation(transit, enter, nextAnim) - } - anim?.let { - anim.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - if (enter) { - onEnterTransitionAnimationEnd() - } - } - }, - ) - } - ?: run { - if (enter) { - onEnterTransitionAnimationEnd() - } - } - return anim - } - - open fun onEnterTransitionAnimationEnd() {} - - companion object { - private val animationsToAdjustZorder = - listOf( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_right, - R.anim.fragment_enter_from_bottom, - R.anim.fragment_exit_to_bottom - ) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt deleted file mode 100644 index 532787ff4f44..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import kotlinx.coroutines.flow.MutableStateFlow -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ConnectScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild -import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ConnectFragment : BaseFragment() { - - // Injected dependencies - private val connectViewModel: ConnectViewModel by viewModel() - private val _setNavigationBar = MutableStateFlow(false) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_compose, container, false) - - view.findViewById(R.id.compose_view).setContent { - AppTheme { - val state = connectViewModel.uiState.collectAsState().value - val drawNavbar = _setNavigationBar.collectAsState() - ConnectScreen( - uiState = state, - uiSideEffect = connectViewModel.uiSideEffect, - drawNavigationBar = drawNavbar.value, - onDisconnectClick = connectViewModel::onDisconnectClick, - onReconnectClick = connectViewModel::onReconnectClick, - onConnectClick = connectViewModel::onConnectClick, - onCancelClick = connectViewModel::onCancelClick, - onSwitchLocationClick = ::openSwitchLocationScreen, - onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, - onUpdateVersionClick = { openDownloadUrl() }, - onManageAccountClick = connectViewModel::onManageAccountClick, - onOpenOutOfTimeScreen = ::openOutOfTimeScreen, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, - ) - } - } - - return view - } - - private fun openDownloadUrl() { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - requireContext().getString(R.string.download_url).appendHideNavOnPlayBuild() - ) - ) - .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } - requireContext().startActivity(intent) - } - - private fun openSwitchLocationScreen() { - parentFragmentManager.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, SelectLocationFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openOutOfTimeScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, OutOfTimeFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } - - override fun onPause() { - super.onPause() - _setNavigationBar.value = false - } - - // TODO Temporary fix for handling in & out animations until we have Compose Navigation - override fun onEnterTransitionAnimationEnd() { - super.onEnterTransitionAnimationEnd() - _setNavigationBar.value = true - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt deleted file mode 100644 index ab6de40dc2c3..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt +++ /dev/null @@ -1,91 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.DeviceListScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class DeviceListFragment : Fragment() { - - private val deviceListViewModel by viewModel() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - deviceListViewModel.accountToken = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = deviceListViewModel.uiState.collectAsState().value - DeviceListScreen( - state = state, - onBackClick = { openLoginView(doTriggerAutoLogin = false) }, - onContinueWithLogin = { openLoginView(doTriggerAutoLogin = true) }, - onSettingsClicked = this@DeviceListFragment::openSettings, - onDeviceRemovalClicked = deviceListViewModel::stageDeviceForRemoval, - onDismissDeviceRemovalDialog = deviceListViewModel::clearStagedDevice, - onConfirmDeviceRemovalDialog = - deviceListViewModel::confirmRemovalOfStagedDevice - ) - } - } - } - } - - override fun onResume() { - super.onResume() - deviceListViewModel.clearStagedDevice() - } - - private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { - deviceListViewModel.toastMessages - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } - } - - private fun openLoginView(doTriggerAutoLogin: Boolean) { - parentActivity()?.clearBackStack() - val loginFragment = - LoginFragment().apply { - if (doTriggerAutoLogin && deviceListViewModel.accountToken != null) { - arguments = - Bundle().apply { - putString(ACCOUNT_TOKEN_ARGUMENT_KEY, deviceListViewModel.accountToken) - } - } - } - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, loginFragment) - commitAllowingStateLoss() - } - } - - private fun parentActivity(): MainActivity? { - return (context as? MainActivity) - } - - private fun openSettings() = parentActivity()?.openSettings() -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt deleted file mode 100644 index 4b744d568a00..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.DeviceRevokedScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class DeviceRevokedFragment : Fragment() { - private val deviceRevokedViewModel: DeviceRevokedViewModel by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = deviceRevokedViewModel.uiState.collectAsState().value - DeviceRevokedScreen( - state = state, - onSettingsClicked = this@DeviceRevokedFragment::openSettingsView, - onGoToLoginClicked = deviceRevokedViewModel::onGoToLoginClicked - ) - } - } - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt deleted file mode 100644 index 7b066b12462c..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -const val ACCOUNT_TOKEN_ARGUMENT_KEY = "accountToken" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt deleted file mode 100644 index d2f0cbfb6e89..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt +++ /dev/null @@ -1,30 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoadingScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity - -class LoadingFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { LoadingScreen(this@LoadingFragment::openSettings) } - } - } - } - - private fun openSettings() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt deleted file mode 100644 index 92d58066ee0b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoginScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.AccountToken -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect -import net.mullvad.mullvadvpn.viewmodel.LoginViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class LoginFragment : BaseFragment() { - private val vm: LoginViewModel by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - - // TODO: Remove this when we have a better solution for login after clearing max devices - val accountTokenArgument = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) - if (accountTokenArgument != null) { - // Login and set initial TextField value - vm.onAccountNumberChange(accountTokenArgument) - vm.login(accountTokenArgument) - } - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState by vm.uiState.collectAsState() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - LoginUiSideEffect.NavigateToWelcome, - LoginUiSideEffect - .NavigateToConnect -> {} // TODO Fix when we redo navigation - is LoginUiSideEffect.TooManyDevices -> { - navigateToDeviceListFragment(it.accountToken) - } - } - } - } - LoginScreen( - uiState, - vm::login, - vm::createAccount, - vm::clearAccountHistory, - vm::onAccountNumberChange, - ::openSettingsView - ) - } - } - } - } - - private fun navigateToDeviceListFragment(accountToken: AccountToken) { - val deviceFragment = - DeviceListFragment().apply { - arguments = - Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken.value) } - } - - parentFragmentManager.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, deviceFragment) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt deleted file mode 100644 index 5a1ae49e1a19..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.OutOfTimeScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class OutOfTimeFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - OutOfTimeScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = ::openRedeemVoucherFragment, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - openConnectScreen = ::advanceToConnectScreen, - onDisconnectClick = vm::onDisconnectClick, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog - ) - } - } - } - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment { wasSuccessful -> if (wasSuccessful) advanceToConnectScreen() } - .show(transaction, null) - } - - private fun advanceToConnectScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, ConnectFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt deleted file mode 100644 index ed4538201363..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.PrivacyDisclaimerScreen -import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild -import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel -import org.koin.android.ext.android.inject - -class PrivacyDisclaimerFragment : Fragment() { - - private val privacyDisclaimerViewModel: PrivacyDisclaimerViewModel by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - PrivacyDisclaimerScreen( - onPrivacyPolicyLinkClicked = { openPrivacyPolicy() }, - onAcceptClicked = { handleAcceptedPrivacyDisclaimer() } - ) - } - } - } - } - - private fun handleAcceptedPrivacyDisclaimer() { - privacyDisclaimerViewModel.setPrivacyDisclosureAccepted() - (activity as? MainActivity)?.initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = activity?.intent?.getApiEndpointConfigurationExtras() - ) - } - - private fun openPrivacyPolicy() { - val privacyPolicyUrlIntent = - Intent( - Intent.ACTION_VIEW, - Uri.parse(getString(R.string.privacy_policy_url).appendHideNavOnPlayBuild()) - ) - context?.startActivity(privacyPolicyUrlIntent) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt deleted file mode 100644 index fd489e563f40..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt +++ /dev/null @@ -1,57 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ReportProblemScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ProblemReportFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState = vm.uiState.collectAsState().value - ReportProblemScreen( - uiState, - onSendReport = { email, description -> vm.sendReport(email, description) }, - onDismissNoEmailDialog = vm::dismissConfirmNoEmail, - onClearSendResult = vm::clearSendResult, - onNavigateToViewLogs = { showLogs() }, - updateEmail = vm::updateEmail, - updateDescription = vm::updateDescription - ) { - activity?.onBackPressed() - } - } - } - } - } - - private fun showLogs() { - parentFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_half_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, ViewLogsFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt deleted file mode 100644 index 2730fde5479d..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.DialogFragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.RedeemVoucherDialogScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class RedeemVoucherDialogFragment(val onDialogDismiss: (Boolean) -> Unit = {}) : DialogFragment() { - - private val vm by viewModel() - private lateinit var voucherDialog: Dialog - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - RedeemVoucherDialogScreen( - uiState = vm.uiState.collectAsState().value, - onVoucherInputChange = { vm.onVoucherInputChange(it) }, - onRedeem = { vm.onRedeem(it) }, - onDismiss = { - onDismiss(voucherDialog) - onDialogDismiss(it) - } - ) - } - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - voucherDialog = super.onCreateDialog(savedInstanceState) - return voucherDialog - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt deleted file mode 100644 index d1c4ac72bfbf..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SelectLocationFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - SelectLocationScreen( - uiState = state, - uiCloseAction = vm.uiCloseAction, - enterTransitionEndAction = vm.enterTransitionEndAction, - onSelectRelay = vm::selectRelay, - onSearchTermInput = vm::onSearchTermInput, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt deleted file mode 100644 index e5faf6bb1171..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.SettingsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SettingsFragment : BaseFragment() { - private val vm by viewModel() - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - SettingsScreen( - uiState = state, - enterTransitionEndAction = vm.enterTransitionEndAction, - onVpnSettingCellClick = { openVpnSettingsFragment() }, - onSplitTunnelingCellClick = { openSplitTunnelingFragment() }, - onReportProblemCellClick = { openReportProblemFragment() }, - onBackClick = { activity?.onBackPressed() } - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } - - private fun openFragment(fragment: Fragment) { - parentFragmentManager.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, fragment) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openVpnSettingsFragment() { - openFragment(VpnSettingsFragment()) - } - - private fun openSplitTunnelingFragment() { - openFragment(SplitTunnelingFragment()) - } - - private fun openReportProblemFragment() { - openFragment(ProblemReportFragment()) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt deleted file mode 100644 index 7004303ae8c1..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.ApplicationsIconManager -import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SplitTunnelingFragment : BaseFragment() { - private val viewModel: SplitTunnelingViewModel by viewModel() - private val applicationsIconManager: ApplicationsIconManager by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = viewModel.uiState.collectAsState().value - SplitTunnelingScreen( - uiState = state, - onShowSystemAppsClick = viewModel::onShowSystemAppsClick, - onExcludeAppClick = viewModel::onExcludeAppClick, - onIncludeAppClick = viewModel::onIncludeAppClick, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - onResolveIcon = { packageName -> - applicationsIconManager.getAppIcon(packageName) - } - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt deleted file mode 100644 index 21931ab87638..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ViewLogsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ViewLogsFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState = vm.uiState.collectAsState() - ViewLogsScreen( - uiState = uiState.value, - onBackClick = { activity?.onBackPressed() } - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt deleted file mode 100644 index 49d43e6b2770..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.VpnSettingsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class VpnSettingsFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - VpnSettingsScreen( - uiState = state, - onMtuCellClick = vm::onMtuCellClick, - onSaveMtuClick = vm::onSaveMtuClick, - onRestoreMtuClick = vm::onRestoreMtuClick, - onCancelMtuDialogClick = vm::onCancelDialogClick, - onToggleAutoConnect = vm::onToggleAutoConnect, - onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing, - onToggleDnsClick = vm::onToggleDnsClick, - onToggleBlockAds = vm::onToggleBlockAds, - onToggleBlockTrackers = vm::onToggleBlockTrackers, - onToggleBlockMalware = vm::onToggleBlockMalware, - onToggleBlockAdultContent = vm::onToggleBlockAdultContent, - onToggleBlockGambling = vm::onToggleBlockGambling, - onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia, - onDnsClick = vm::onDnsClick, - onDnsInputChange = vm::onDnsInputChange, - onSaveDnsClick = vm::onSaveDnsClick, - onRemoveDnsClick = vm::onRemoveDnsClick, - onCancelDnsDialogClick = vm::onCancelDns, - onLocalNetworkSharingInfoClick = vm::onLocalNetworkSharingInfoClick, - onContentsBlockersInfoClick = vm::onContentsBlockerInfoClick, - onCustomDnsInfoClick = vm::onCustomDnsInfoClick, - onMalwareInfoClick = vm::onMalwareInfoClick, - onDismissInfoClick = vm::onDismissInfoClick, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - onStopEvent = vm::onStopEvent, - toastMessagesSharedFlow = vm.toastMessages, - onSelectObfuscationSetting = vm::onSelectObfuscationSetting, - onObfuscationInfoClick = vm::onObfuscationInfoClick, - onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, - onQuantumResistanceInfoClicked = vm::onQuantumResistanceInfoClicked, - onWireguardPortSelected = vm::onWireguardPortSelected, - onWireguardPortInfoClicked = vm::onWireguardPortInfoClicked, - onShowCustomPortDialog = vm::onShowCustomPortDialog, - onCancelCustomPortDialogClick = vm::onCancelDialogClick, - onCloseCustomPortDialog = vm::onCancelDialogClick - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt deleted file mode 100644 index 5c5e0c83f85b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.WelcomeScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class WelcomeFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - WelcomeScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = ::openRedeemVoucherFragment, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - openConnectScreen = ::advanceToConnectScreen, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog - ) - } - } - } - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment { wasSuccessful -> if (wasSuccessful) advanceToConnectScreen() } - .show(transaction, null) - } - - private fun advanceToConnectScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, ConnectFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } -} 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 7f44b0c7d486..4e1d773f1e42 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/usecase/OutOfTimeUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt new file mode 100644 index 000000000000..e918259be726 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt @@ -0,0 +1,62 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +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.AccountExpiry +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime + +const val accountRefreshInterval = 1000L * 60L // 1 minute + +class OutOfTimeUseCase( + private val repository: AccountRepository, + private val messageHandler: MessageHandler +) { + + fun isOutOfTime() = + combine(pastAccountExpiry(), isTunnelBlockedBecauseOutOfTime()) { + accountExpiryHasPast, + tunnelOutOfTime -> + accountExpiryHasPast or tunnelOutOfTime + } + .distinctUntilChanged() + + private fun isTunnelBlockedBecauseOutOfTime() = + messageHandler + .events() + .map { it.tunnelState.isTunnelErrorStateDueToExpiredAccount() } + .onStart { emit(false) } + + private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { + return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed) + ?.isCausedByExpiredAccount() + ?: false + } + + private fun pastAccountExpiry(): Flow = + combine(repository.accountExpiryState, timeFlow()) { accountExpiryState, time -> + when (accountExpiryState) { + is AccountExpiry.Available -> { + accountExpiryState.date()?.isBefore(time) ?: false + } + AccountExpiry.Missing -> false + } + } + + private fun timeFlow() = flow { + while (true) { + emit(DateTime.now()) + delay(accountRefreshInterval) + } + } +} 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 000000000000..fd004562a342 --- /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 a754b5a6c29e..00b0a9305b5a 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,38 +1,14 @@ 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.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flatMapLatest -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 = - 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 Flow.flatMapReadyConnectionOrDefault( default: Flow, transform: (value: ServiceConnectionState.ConnectedReady) -> Flow @@ -129,3 +105,10 @@ inline fun combine( suspend inline fun Deferred.awaitWithTimeoutOrNull(timeout: Long) = withTimeoutOrNull(timeout) { await() } + +fun Deferred.getOrDefault(default: T) = + try { + getCompleted() + } catch (e: IllegalStateException) { + default + } 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 0a5167da2ed5..0f0708707eb8 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.isCustom() = is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) } -fun Constraint.toDisplayCustomPort() = +fun Constraint.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 5f721674990a..9146bb110e23 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 @@ -10,8 +10,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine 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 +20,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 @@ -32,30 +31,24 @@ class AccountViewModel( ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - private val _enterTransitionEndAction = MutableSharedFlow() - val uiSideEffect = _uiSideEffect.asSharedFlow() val uiState: StateFlow = 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, billingPaymentState = paymentAvailability?.toPaymentState() ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() - init { updateAccountExpiry() verifyPurchases() @@ -74,10 +67,11 @@ class AccountViewModel( fun onLogoutClick() { accountRepository.logout() + viewModelScope.launch { _uiSideEffect.emit(UiSideEffect.NavigateToLogin) } } - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + fun onCopyAccountNumber(accountNumber: String) { + viewModelScope.launch { _uiSideEffect.emit(UiSideEffect.CopyAccountNumber(accountNumber)) } } fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { @@ -115,7 +109,11 @@ class AccountViewModel( } sealed class UiSideEffect { + data object NavigateToLogin : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() + + data class CopyAccountNumber(val accountNumber: String) : UiSideEffect() } } @@ -123,8 +121,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() = @@ -132,8 +130,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 f6549cded6a9..542a5e048f9c 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.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(replay = 1, extraBufferCapacity = 1) + val uiSideEffect: SharedFlow = _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) : 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) : 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 76c290f439c0..3edaf1aefa73 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 @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -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,6 +53,7 @@ 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(extraBufferCapacity = 1) @@ -90,9 +93,6 @@ class ConnectViewModel( accountExpiry, isTunnelInfoExpanded, deviceName -> - if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) { - _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView) - } ConnectUiState( location = when (tunnelRealState) { @@ -136,6 +136,12 @@ class ConnectViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) init { + viewModelScope.launch { + // This once we get isOutOfTime true we will navigate to OutOfTime view. + outOfTimeUseCase.isOutOfTime().filter { it }.first() + _uiSideEffect.emit(UiSideEffect.OutOfTime) + } + // The create account cache is no longer needed as we have successfully reached the connect // screen accountRepository.clearCreatedAccountCache() @@ -155,12 +161,6 @@ class ConnectViewModel( private fun ConnectionProxy.tunnelRealStateFlow(): Flow = 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() } @@ -198,7 +198,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 98648e0015bb..19a458114abf 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 @@ -7,8 +7,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow 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 @@ -33,22 +33,15 @@ class DeviceListViewModel( private val resources: Resources, private val dispatcher: CoroutineDispatcher = Dispatchers.Default ) : ViewModel() { - private val _stagedDeviceId = MutableStateFlow(null) private val _loadingDevices = MutableStateFlow>(emptyList()) - private val _toastMessages = MutableSharedFlow(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect: MutableSharedFlow = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect - @Suppress("konsist.ensure public properties use permitted names") - var accountToken: String? = null private var cachedDeviceList: List? = 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 +58,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 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() - } + fun removeDevice(accountToken: String, deviceIdToRemove: DeviceId) { + + 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.emit( + 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 +108,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 000000000000..ed3bff396cf0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -0,0 +1,170 @@ +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.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.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +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, + 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 = + combine(_ipAddressInput, vmState, ::createViewState) + .stateIn( + viewModelScope, + SharingStarted.Lazily, + createViewState(_ipAddressInput.value, vmState.value) + ) + + private val _uiSideEffect = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect + + 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 + ): 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.emit(DnsDialogSideEffect.Complete) + } + + fun onRemoveDnsClick() = + viewModelScope.launch(dispatcher) { + repository.updateCustomDnsList { + it.filter { it.hostAddress != uiState.value.ipAddress } + } + _uiSideEffect.emit(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 + ): 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/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 87174f2063bc..8980941e6d85 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 @@ -12,6 +12,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -21,12 +25,14 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.* 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 import net.mullvad.mullvadvpn.repository.DeviceRepository 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 @@ -35,6 +41,8 @@ sealed interface LoginUiSideEffect { data object NavigateToConnect : LoginUiSideEffect + data object NavigateToOutOfTime : LoginUiSideEffect + data class TooManyDevices(val accountToken: AccountToken) : LoginUiSideEffect } @@ -87,8 +95,19 @@ class LoginViewModel( when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) { LoginResult.Ok -> { launch { + val isOutOfTimeDeferred = async { + accountRepository.accountExpiryState + .filterIsInstance() + .map { it.expiryDateTime.isBeforeNow } + .first() + } delay(1000) - _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect) + val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) + if (isOutOfTime) { + _uiSideEffect.emit(LoginUiSideEffect.NavigateToOutOfTime) + } else { + _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect) + } } newDeviceNotificationUseCase.newDeviceCreated() Success @@ -106,10 +125,11 @@ class LoginViewModel( if (refreshResult.isAvailable()) { // Navigate to device list + _uiSideEffect.emit( LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) ) - return@launch + Idle() } else { // Failed to fetch devices list Idle(LoginError.Unknown(result.toString())) 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 000000000000..067a26c79fed --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +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 = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect + + fun onSaveClick(mtuValue: Int) = + viewModelScope.launch(dispatcher) { + if (mtuValue.isValidMtu()) { + repository.setWireguardMtu(mtuValue) + } + _uiSideEffect.emit(MtuDialogSideEffect.Complete) + } + + fun onRestoreClick() = + viewModelScope.launch(dispatcher) { + repository.setWireguardMtu(null) + _uiSideEffect.emit(MtuDialogSideEffect.Complete) + } +} + +sealed interface MtuDialogSideEffect { + data object Complete : MtuDialogSideEffect +} 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 675ca7ef9439..771b64b760f0 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,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.delay @@ -17,7 +16,7 @@ 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 @@ -28,7 +27,6 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy 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 @@ -57,13 +55,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() ) } } @@ -110,10 +107,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() 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 000000000000..7f210721df22 --- /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 = + 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 c3b63bb818e4..5ddb172d7fb7 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,25 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class PrivacyDisclaimerViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository ) : ViewModel() { - fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + private val _uiSideEffect = + MutableSharedFlow(extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + fun setPrivacyDisclosureAccepted() { + privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + viewModelScope.launch { _uiSideEffect.emit(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 82e66b0c4b98..896355a43d20 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 @@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -16,7 +18,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 +31,24 @@ sealed interface SendingReportUiState { data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState } +sealed interface ReportProblemSideEffect { + data class ShowConfirmNoEmail(val email: String, val description: String) : + ReportProblemSideEffect +} + class ReportProblemViewModel( private val mullvadProblemReporter: MullvadProblemReport, private val problemReportRepository: ProblemReportRepository ) : ViewModel() { - private val showConfirmNoEmail = MutableStateFlow(false) private val sendingState: MutableStateFlow = 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 = MutableSharedFlow() + val uiSideEffect = _uiSideEffect.asSharedFlow() + + 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.emit(ReportProblemSideEffect.ShowConfirmNoEmail(email, description)) } 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 5e95674e0a6a..8928118d3023 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 @@ -20,8 +20,6 @@ class SelectLocationViewModel( private val serviceConnectionManager: ServiceConnectionManager, private val relayListUseCase: RelayListUseCase ) : ViewModel() { - private val _closeAction = MutableSharedFlow() - private val _enterTransitionEndAction = MutableSharedFlow() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = @@ -45,19 +43,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 = MutableSharedFlow() + val uiSideEffect = _uiSideEffect.asSharedFlow() 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.emit(SelectLocationSideEffect.CloseScreen) } } fun onSearchTermInput(searchTerm: String) { @@ -68,3 +60,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/ServiceConnectionViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt new file mode 100644 index 000000000000..8884d544b2ac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServiceConnectionViewModel.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState + +class ServiceConnectionViewModel(serviceConnectionManager: ServiceConnectionManager) : ViewModel() { + val uiState = + serviceConnectionManager.connectionState + .map { + when (it) { + is ServiceConnectionState.ConnectedNotReady -> ServiceState.Disconnected + is ServiceConnectionState.ConnectedReady -> ServiceState.Connected + ServiceConnectionState.Disconnected -> ServiceState.Disconnected + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = ServiceState.Disconnected + ) +} + +sealed interface ServiceState { + data object Disconnected : ServiceState + + data object Connected : ServiceState +} 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 fb357dfe2a3a..8ef85cfca820 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() private val vmState: StateFlow = 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 000000000000..29b1262a537e --- /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.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +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 = + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + fun start() { + viewModelScope.launch { + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + _uiSideEffect.emit(getStartDestination()) + } else { + _uiSideEffect.emit(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().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 802245bb1d46..85966d1443b8 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 dfae3df53956..25444458a504 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 @@ -12,6 +12,8 @@ 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.stateIn import kotlinx.coroutines.flow.update @@ -32,29 +34,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(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() - private val dialogState = MutableStateFlow(null) + private val customPort = MutableStateFlow?>(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 +68,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 +91,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 +113,19 @@ class VpnSettingsViewModel( viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) } } - fun onToggleDnsClick(isEnabled: Boolean) { - updateCustomDnsState(isEnabled) - if (isEnabled && vmState.value.customDnsList.isEmpty()) { - onDnsClick(null) + fun onDnsDialogDismiss() { + if (vmState.value.customDnsList.isEmpty()) { + onToggleDnsClick(false) + } + } + + fun onToggleDnsClick(enable: Boolean) { + repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default) + if (enable && vmState.value.customDnsList.isEmpty()) { + viewModelScope.launch { _uiSideEffect.emit(VpnSettingsSideEffect.NavigateToDnsDialog) } + } else { + showApplySettingChangesWarningToast() } - showApplySettingChangesWarningToast() } fun onToggleBlockAds(isEnabled: Boolean) { @@ -281,29 +170,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 +187,24 @@ 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) { + 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 } + relayListUseCase.updateSelectedWireguardConstraints( + WireguardConstraints(port = Constraint.Any()) + ) } private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = @@ -354,26 +216,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.asInetAddressList(): List { return try { map { InetAddress.getByName(it) } @@ -408,32 +250,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.emit( + 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 2ebc2b397cac..fd236e840573 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, val contentBlockersOptions: DefaultDnsOptions, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint, + val customWireguardPort: Constraint?, val availablePortRanges: List, - 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 566aa319819f..8607b796c057 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,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview @@ -19,7 +18,7 @@ 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 @@ -31,7 +30,6 @@ 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 @@ -62,14 +60,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() ) } } @@ -112,10 +109,6 @@ class WelcomeViewModel( } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } - private fun verifyPurchases() { viewModelScope.launch { paymentUseCase.verifyPurchases() @@ -123,7 +116,6 @@ class WelcomeViewModel( } } - @OptIn(FlowPreview::class) private fun fetchPaymentAvailability() { viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } } diff --git a/android/app/src/main/res/layout/fragment_compose.xml b/android/app/src/main/res/layout/fragment_compose.xml deleted file mode 100644 index a3a147d9971f..000000000000 --- a/android/app/src/main/res/layout/fragment_compose.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/android/app/src/main/res/layout/main.xml b/android/app/src/main/res/layout/main.xml deleted file mode 100644 index 8e7356e766ee..000000000000 --- a/android/app/src/main/res/layout/main.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt new file mode 100644 index 000000000000..5726017d83b1 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt @@ -0,0 +1,100 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +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.AccountExpiry +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime +import org.junit.Before +import org.junit.Test + +class OutOfTimeUseCaseTest { + + private val mockAccountRepository: AccountRepository = mockk() + private val mockMessageHandler: MessageHandler = mockk() + + private val events = MutableSharedFlow() + private val expiry = MutableStateFlow(AccountExpiry.Missing) + + lateinit var outOfTimeUseCase: OutOfTimeUseCase + + @Before + fun setup() { + every { mockAccountRepository.accountExpiryState } returns expiry + every { mockMessageHandler.events() } returns events + outOfTimeUseCase = OutOfTimeUseCase(mockAccountRepository, mockMessageHandler) + } + + @Test + fun `No events should result in no expiry`() = runTest { + // Arrange + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { assertEquals(false, awaitItem()) } + } + + @Test + fun `Tunnel is blocking because out of time should emit true`() = runTest { + // Arrange + // Act, Assert + val errorStateCause = ErrorStateCause.AuthFailed("[EXPIRED_ACCOUNT]") + val tunnelStateError = TunnelState.Error(ErrorState(errorStateCause, true)) + val errorChange = Event.TunnelStateChange(tunnelStateError) + + outOfTimeUseCase.isOutOfTime().test { + assertEquals(false, awaitItem()) + events.emit(errorChange) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `Account expiry that has expired should emit true`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().minusDays(1)) + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + assertEquals(false, awaitItem()) + expiry.emit(expiredAccountExpiry) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `Account expiry that has not expired should emit nothing`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + assertEquals(false, awaitItem()) + expiry.emit(expiredAccountExpiry) + expectNoEvents() + } + } + + @Test + fun `Account that expires without new expiry event`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(2)) + + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + // Initial event that doesn't change + assertEquals(false, awaitItem()) + expiry.emit(expiredAccountExpiry) + + assertEquals(true, awaitItem()) + } + } +} 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 c02e75595158..282d1d3a2799 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 @@ -160,29 +157,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 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 e223a1253980..742f28e77d88 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,27 +8,30 @@ 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 private lateinit var viewModel: ChangelogViewModel + private val buildVersionCode = 10 + @Before fun setup() { MockKAnnotations.init(this) mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just Runs - viewModel = ChangelogViewModel(mockedChangelogRepository, 1, false) } @After @@ -37,50 +40,36 @@ class ChangelogViewModelTest { } @Test - fun testInitialState() = runTest { - // Arrange, Act, Assert - viewModel.uiState.test { assertEquals(ChangelogDialogUiState.Hide, awaitItem()) } + fun testUpToDateVersionCode() = 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 testNotUpToDateVersionCode() = 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() - every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - -1 - every { mockedChangelogRepository.getLastVersionChanges() } returns fakeEmptyList - - // Assert initial ui state - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) + fun testNotUpToDateVersionCodeWithEmptyChangeLog() = 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 { 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 345a57df8097..35898df4abbc 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(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(deferred.await()) + assertIs(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 2ada5bf767b7..4f16bf70f6df 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 @@ -18,6 +18,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.* 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 @@ -25,6 +26,7 @@ import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import org.joda.time.DateTime import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -86,6 +88,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 dad51eab59ae..bbc047cfe612 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 @@ -38,7 +34,6 @@ 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.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.joda.time.ReadableInstant @@ -233,44 +228,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 000000000000..665e23c3d42d --- /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(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/ReportProblemModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt index e0b1c5274c5e..e11a42efff78 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt @@ -87,16 +87,30 @@ class ReportProblemModelTest { 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(false, null), awaitItem()) - viewModel.sendReport(email, "My description") - assertEquals(ReportProblemUiState(true, null), awaitItem()) - viewModel.sendReport(email, "My description") - assertEquals(ReportProblemUiState(false, SendingReportUiState.Sending), awaitItem()) + 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(false, SendingReportUiState.Success(null)), + ReportProblemUiState( + SendingReportUiState.Success(null), + "", + "", + ), awaitItem() ) } @@ -109,16 +123,42 @@ class ReportProblemModelTest { coEvery { mockMullvadProblemReport.sendReport(any()) } returns SendProblemReportResult.Success val email = "my@email.com" + val description = "My description" + + 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(false, null)) - viewModel.sendReport(email, "My description") + assertEquals(awaitItem(), ReportProblemUiState(null, "", "")) + viewModel.updateEmail(email) + awaitItem() + viewModel.updateDescription(description) + awaitItem() + + viewModel.sendReport(email, description) - assertEquals(awaitItem(), ReportProblemUiState(false, SendingReportUiState.Sending)) assertEquals( - awaitItem(), - ReportProblemUiState(false, SendingReportUiState.Success(email)) + ReportProblemUiState( + SendingReportUiState.Sending, + email, + description, + ), + awaitItem() + ) + assertEquals( + ReportProblemUiState( + SendingReportUiState.Success(email), + "", + "", + ), + awaitItem() ) } } @@ -146,19 +186,4 @@ class ReportProblemModelTest { // Assert verify { mockProblemReportRepository.setDescription(description) } } - - @Test - fun testUpdateProblemReport() = runTest { - // Arrange - val userReport = UserReport("my@email.com", "My description") - - // Act, Assert - viewModel.uiState.test { - awaitItem() - problemReportFlow.value = userReport - val result = awaitItem() - assertEquals(userReport.email, result.email) - assertEquals(userReport.description, result.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 44be67fa648f..5e3b07cca797 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 @@ -105,10 +105,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 f8736eb823ea..0ac13777cd84 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>(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(mockk(), mockk()) - val mockSettings: Settings = mockk(relaxed = true) - - every { mockSettings.relaySettings } returns mockk(relaxed = true) - portRangeFlow.value = expectedPortRange - - // Act, Assert - viewModel.uiState.test { - assertIs(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 e958df9337c1..9ec642dcc899 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 @@ -38,7 +33,6 @@ 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.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.joda.time.ReadableInstant @@ -142,27 +136,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()) + paymentAvailability.value = null + deviceState.value = + DeviceState.LoggedIn( + accountAndDevice = + AccountAndDevice(account_token = expectedAccountNumber, device = device) + ) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + assertEquals(expectedAccountNumber, awaitItem().accountNumber) } + } @Test fun testOpenConnectScreen() = @@ -244,46 +237,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/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 57af45997b17..a77e8798cc3a 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -45,6 +45,8 @@ object Dependencies { } object Compose { + const val destinations = "io.github.raamcosta.compose-destinations:core:${Versions.Compose.destinations}" + const val destinationsKsp = "io.github.raamcosta.compose-destinations:ksp:${Versions.Compose.destinations}" const val constrainLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" const val foundation = @@ -130,5 +132,6 @@ object Dependencies { const val dependencyCheckId = "org.owasp.dependencycheck" const val gradleVersionsId = "com.github.ben-manes.versions" const val ktfmtId = "com.ncorti.ktfmt.gradle" + const val ksp = "com.google.devtools.ksp" } } diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 06d5392f2f1c..7908221348e2 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -4,8 +4,8 @@ object Versions { const val junit = "4.13.2" const val jvmTarget = "17" const val konsist = "0.13.0" - const val kotlin = "1.9.10" - const val kotlinCompilerExtensionVersion = "1.5.3" + const val kotlin = "1.9.20" + const val kotlinCompilerExtensionVersion = "1.5.4" const val kotlinx = "1.7.3" const val leakCanary = "2.12" const val mockk = "1.13.8" @@ -39,7 +39,8 @@ object Versions { } object Compose { - const val base = "1.5.1" + const val destinations = "1.9.55" + const val base = "1.5.4" const val constrainLayout = "1.0.1" const val foundation = base const val material3 = "1.1.1" @@ -48,6 +49,7 @@ object Versions { } object Plugin { + // The androidAapt plugin version must be in sync with the android plugin version. // Required for Gradle metadata verification to work properly, see: // https://github.com/gradle/gradle/issues/19228 @@ -57,6 +59,9 @@ object Versions { const val dependencyCheck = "8.3.1" const val gradleVersions = "0.47.0" const val ktfmt = "0.13.0" + // Ksp version is linked with kotlin version, find matching release here: + // https://github.com/google/ksp/releases + const val ksp = "${kotlin}-1.0.14" } object Koin { diff --git a/android/config/dependency-check-suppression.xml b/android/config/dependency-check-suppression.xml index 57e19a9d54b1..d2bb95dc9a85 100644 --- a/android/config/dependency-check-suppression.xml +++ b/android/config/dependency-check-suppression.xml @@ -51,4 +51,13 @@ ^pkg:maven/com\.squareup\.okio/okio@.*$ CVE-2023-3635 + + + ^pkg:maven/com\.google\.devtools\.ksp/symbol\-processing.*@.*$ + CVE-2018-1000840 + diff --git a/android/docs/diagrams/nav_graph.dot b/android/docs/diagrams/nav_graph.dot new file mode 100644 index 000000000000..7925bb533d52 --- /dev/null +++ b/android/docs/diagrams/nav_graph.dot @@ -0,0 +1,48 @@ +digraph { + { + node [shape=rectangle] + splash [label="Splash"] + login [label="Login"] + connect [label="Connect"] + too_many_devices [label="Too many devices"] + welcome [label="Welcome"] + revoked [label="Revoked"] + privacy_policy [label="Privacy policy"] + account [label="Account"] + settings [label = "Settings"] + report_problem [label = "Report problem"] + view_logs [label = "View logs"] + split_tunneling [label = "Split tunneling"] + vpn_settings [label = "VPN settings"] + switch_location [label = "Switch location"] + } + + splash -> privacy_policy + splash -> login + splash -> connect + splash -> revoked + + + revoked -> login + privacy_policy -> login + + login -> welcome + login -> too_many_devices + login -> settings + login -> connect + + too_many_devices -> login + + welcome -> connect + + connect -> revoked + connect -> settings + connect -> account + connect -> switch_location + + settings -> vpn_settings + settings -> split_tunneling + settings -> report_problem + + report_problem -> view_logs +} diff --git a/android/docs/diagrams/nav_graph.svg b/android/docs/diagrams/nav_graph.svg new file mode 100644 index 000000000000..f223a1f6bde5 --- /dev/null +++ b/android/docs/diagrams/nav_graph.svg @@ -0,0 +1,216 @@ + + + + + + + + + +splash + +Splash + + + +login + +Login + + + +splash->login + + + + + +connect + +Connect + + + +splash->connect + + + + + +revoked + +Revoked + + + +splash->revoked + + + + + +privacy_policy + +Privacy policy + + + +splash->privacy_policy + + + + + +login->connect + + + + + +too_many_devices + +Too many devices + + + +login->too_many_devices + + + + + +welcome + +Welcome + + + +login->welcome + + + + + +settings + +Settings + + + +login->settings + + + + + +connect->revoked + + + + + +account + +Account + + + +connect->account + + + + + +connect->settings + + + + + +switch_location + +Switch location + + + +connect->switch_location + + + + + +too_many_devices->login + + + + + +welcome->connect + + + + + +revoked->login + + + + + +privacy_policy->login + + + + + +report_problem + +Report problem + + + +settings->report_problem + + + + + +split_tunneling + +Split tunneling + + + +settings->split_tunneling + + + + + +vpn_settings + +VPN settings + + + +settings->vpn_settings + + + + + +view_logs + +View logs + + + +report_problem->view_logs + + + + + diff --git a/android/docs/diagrams/update_graph.sh b/android/docs/diagrams/update_graph.sh new file mode 100755 index 000000000000..8b42e5a1ddb4 --- /dev/null +++ b/android/docs/diagrams/update_graph.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +dot $SCRIPT_DIR/nav_graph.dot -Tsvg -o $SCRIPT_DIR/nav_graph.svg diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 6fc79e4bd9cb..b90ce24745f2 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -285,20 +285,20 @@ - - - + + + - + - - - + + + - + @@ -314,28 +314,28 @@ - - - + + + - + - - - + + + - + - - - + + + - - + + @@ -346,20 +346,20 @@ - - - + + + - + - - - + + + - + @@ -375,20 +375,20 @@ - - - + + + - + - - - + + + - + @@ -443,20 +443,20 @@ - - - + + + - + - - - + + + - + @@ -469,36 +469,36 @@ - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + @@ -506,20 +506,20 @@ - - - + + + - + - - - + + + - + @@ -527,60 +527,60 @@ - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - - + + @@ -588,68 +588,68 @@ - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + - - - + + + - + @@ -667,20 +667,20 @@ - - - + + + - + - - - + + + - + @@ -688,20 +688,20 @@ - - - + + + - + - - - + + + - + @@ -1023,6 +1023,14 @@ + + + + + + + + @@ -1031,6 +1039,14 @@ + + + + + + + + @@ -1044,6 +1060,14 @@ + + + + + + + + @@ -1070,6 +1094,14 @@ + + + + + + + + @@ -1083,6 +1115,14 @@ + + + + + + + + @@ -1099,6 +1139,14 @@ + + + + + + + + @@ -1130,6 +1178,14 @@ + + + + + + + + @@ -1143,6 +1199,14 @@ + + + + + + + + @@ -1174,14 +1238,27 @@ - - - + + + + + + + + + + + + + + + + @@ -1190,6 +1267,14 @@ + + + + + + + + @@ -1216,6 +1301,14 @@ + + + + + + + + @@ -1226,6 +1319,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2072,6 +2205,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2552,6 +2719,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3175,14 +3366,14 @@ - - - + + + - - - + + + @@ -3198,14 +3389,19 @@ - - - + + + - - - + + + + + + + + @@ -3218,9 +3414,9 @@ - - - + + + @@ -3228,9 +3424,9 @@ - - - + + + @@ -3238,9 +3434,9 @@ - - - + + + @@ -3248,9 +3444,9 @@ - - - + + + @@ -3261,12 +3457,12 @@ - - - + + + - - + + @@ -3274,9 +3470,9 @@ - - - + + + @@ -3290,15 +3486,12 @@ - - - - - - + + + - - + + @@ -3309,12 +3502,12 @@ - - - + + + - - + + @@ -3322,9 +3515,9 @@ - - - + + + @@ -3335,12 +3528,12 @@ - - - + + + - - + + @@ -3348,9 +3541,9 @@ - - - + + + @@ -3358,9 +3551,9 @@ - - - + + + @@ -3368,9 +3561,9 @@ - - - + + + @@ -3378,19 +3571,19 @@ - - - + + + - - - + + + - - - + + + @@ -3398,9 +3591,9 @@ - - - + + + @@ -3423,6 +3616,11 @@ + + + + + @@ -3441,14 +3639,19 @@ + + + + + - - - + + + @@ -3456,9 +3659,9 @@ - - - + + + @@ -3466,9 +3669,9 @@ - - - + + + @@ -3476,9 +3679,9 @@ - - - + + + @@ -3501,9 +3704,15 @@ - - - + + + + + + + + + @@ -3526,9 +3735,9 @@ - - - + + + @@ -3536,11 +3745,21 @@ + + + + + + + + + + @@ -3551,6 +3770,21 @@ + + + + + + + + + + + + + + + @@ -3561,11 +3795,21 @@ + + + + + + + + + + @@ -3576,6 +3820,21 @@ + + + + + + + + + + + + + + + @@ -3589,12 +3848,12 @@ - - - + + + - - + + @@ -3602,9 +3861,9 @@ - - - + + + @@ -3612,17 +3871,17 @@ - - - + + + - - - + + + - - + + @@ -3630,9 +3889,9 @@ - - - + + + @@ -3640,9 +3899,9 @@ - - - + + + @@ -3650,9 +3909,9 @@ - - - + + + 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 c95c8c9111df..fe9564c45d44 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 008eb1ea7ae0..f003ee316ba9 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/anim/do_nothing.xml b/android/lib/resource/src/main/res/anim/do_nothing.xml deleted file mode 100644 index 8cb6866d6db7..000000000000 --- a/android/lib/resource/src/main/res/anim/do_nothing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml b/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml deleted file mode 100644 index 337392e881b4..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml b/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml deleted file mode 100644 index 5ba3b5c3f8f6..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml deleted file mode 100644 index dc1261114ae4..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml deleted file mode 100644 index 9ffa2c987758..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml deleted file mode 100644 index d79420098292..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml b/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml deleted file mode 100644 index 67e7b7364e37..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml b/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml deleted file mode 100644 index bfac81df2ecf..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/drawable/icon_fail.xml b/android/lib/resource/src/main/res/drawable/icon_fail.xml index 1bb4906a3368..3f934a13003c 100644 --- a/android/lib/resource/src/main/res/drawable/icon_fail.xml +++ b/android/lib/resource/src/main/res/drawable/icon_fail.xml @@ -1,12 +1,10 @@ - - + android:width="44dp" + android:height="44dp" + android:viewportWidth="44" + android:viewportHeight="44"> + + diff --git a/android/lib/resource/src/main/res/drawable/icon_success.xml b/android/lib/resource/src/main/res/drawable/icon_success.xml index fc8627e9b6a0..1b15d4c1c4a0 100644 --- a/android/lib/resource/src/main/res/drawable/icon_success.xml +++ b/android/lib/resource/src/main/res/drawable/icon_success.xml @@ -1,13 +1,11 @@ - - + android:width="44dp" + android:height="44dp" + android:viewportWidth="44.0" + android:viewportHeight="44.0"> + + diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index b10dd68ce372..dbaeab4e13ed 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -158,7 +158,9 @@ VPN permission error Always-on VPN might be enabled for another app NEW DEVICE CREATED - %s. For more details see the info button in Account.]]> + + %s. For more details see the info button in Account.]]> + Agree and continue Privacy 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. diff --git a/android/lib/resource/src/main/res/values/styles.xml b/android/lib/resource/src/main/res/values/styles.xml index 69ee118cac15..5c94a24ebff8 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 @@