diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a3035f0a12da..51673f611c46 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,11 +9,12 @@ 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 val extraAssetsDirectory = "${project.buildDir}/extraAssets" -val defaultChangeLogAssetsDirectory = "$repoRootPath/android/src/main/play/release-notes/" +val defaultChangelogAssetsDirectory = "$repoRootPath/android/src/main/play/release-notes/" val extraJniDirectory = "${project.buildDir}/extraJni" val credentialsPath = "${rootProject.projectDir}/credentials" @@ -111,7 +112,7 @@ android { getByName("main") { val changelogDir = gradleLocalProperties(rootProject.projectDir) - .getOrDefault("OVERRIDE_CHANGELOG_DIR", defaultChangeLogAssetsDirectory) + .getOrDefault("OVERRIDE_CHANGELOG_DIR", defaultChangelogAssetsDirectory) assets.srcDirs(extraAssetsDirectory, changelogDir) jniLibs.srcDirs(extraJniDirectory) @@ -337,6 +338,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..bc8d87b24493 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt @@ -0,0 +1,120 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState +import org.junit.Rule +import org.junit.Test + +class DnsDialogTest { + @get:Rule val composeTestRule = createComposeRule() + + private val defaultState = + DnsDialogViewState( + ipAddress = "", + validationResult = DnsDialogViewState.ValidationResult.Success, + isLocal = false, + isAllowLanEnabled = false, + isNewEntry = true + ) + + @SuppressLint("ComposableNaming") + @Composable + private fun testDnsDialog( + state: DnsDialogViewState = defaultState, + onDnsInputChange: (String) -> Unit = { _ -> }, + onSaveDnsClick: () -> Unit = {}, + onRemoveDnsClick: () -> Unit = {}, + onDismiss: () -> Unit = {} + ) { + DnsDialog(state, onDnsInputChange, onSaveDnsClick, onRemoveDnsClick, onDismiss) + } + + @Test + fun testDnsDialogLanWarningShownWhenLanTrafficDisabledAndLocalAddressUsed() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog(defaultState.copy(isAllowLanEnabled = false, isLocal = true)) + } + + // Assert + composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertExists() + } + + @Test + fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndLocalAddressUsed() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog(defaultState.copy(isAllowLanEnabled = true, isLocal = true)) + } + + // Assert + composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist() + } + + @Test + fun testDnsDialogLanWarningNotShownWhenLanTrafficEnabledAndNonLocalAddressUsed() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog(defaultState.copy(isAllowLanEnabled = true, isLocal = false)) + } + + // Assert + composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist() + } + + @Test + fun testDnsDialogLanWarningNotShownWhenLanTrafficDisabledAndNonLocalAddressUsed() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog(defaultState.copy(isAllowLanEnabled = false, isLocal = false)) + } + + // Assert + composeTestRule.onNodeWithText(LOCAL_DNS_SERVER_WARNING).assertDoesNotExist() + } + + @Test + fun testDnsDialogSubmitButtonDisabledOnInvalidDnsAddress() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog( + defaultState.copy( + ipAddress = invalidIpAddress, + validationResult = DnsDialogViewState.ValidationResult.InvalidAddress, + ) + ) + } + + // Assert + composeTestRule.onNodeWithText("Submit").assertIsNotEnabled() + } + + @Test + fun testDnsDialogSubmitButtonDisabledOnDuplicateDnsAddress() { + // Arrange + composeTestRule.setContentWithTheme { + testDnsDialog( + defaultState.copy( + ipAddress = "192.168.0.1", + validationResult = DnsDialogViewState.ValidationResult.DuplicateAddress, + ) + ) + } + + // Assert + composeTestRule.onNodeWithText("Submit").assertIsNotEnabled() + } + + companion object { + private const val LOCAL_DNS_SERVER_WARNING = + "The local DNS server will not work unless you enable " + + "\"Local Network Sharing\" under Preferences." + + private const val invalidIpAddress = "300.300.300.300" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt new file mode 100644 index 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..1a626ecf1913 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt @@ -0,0 +1,57 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.util.toPaymentDialogData +import org.junit.Rule +import org.junit.Test + +class PaymentDialogTest { + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testShowPurchaseCompleteDialog() { + // Arrange + composeTestRule.setContentWithTheme { + PaymentDialog( + paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()!! + ) + } + + // Assert + composeTestRule.onNodeWithText("Time was successfully added").assertExists() + } + + @Test + fun testShowVerificationErrorDialog() { + // Arrange + composeTestRule.setContentWithTheme { + PaymentDialog( + paymentDialogData = + PurchaseResult.Error.VerificationError(null).toPaymentDialogData()!! + ) + } + + // Assert + composeTestRule.onNodeWithText("Verifying purchase").assertExists() + } + + @Test + fun testShowFetchProductsErrorDialog() { + // Arrange + composeTestRule.setContentWithTheme { + PaymentDialog( + paymentDialogData = + PurchaseResult.Error.FetchProductsError(ProductId(""), null) + .toPaymentDialogData()!! + ) + } + + // Assert + composeTestRule.onNodeWithText("Google Play unavailable").assertExists() + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt index 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..4e34fe082525 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt @@ -9,10 +9,9 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.verify -import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState +import net.mullvad.mullvadvpn.viewmodel.Changelog import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.junit.Before import org.junit.Rule @@ -29,17 +28,17 @@ class ChangelogDialogTest { } @Test - fun testShowChangeLogWhenNeeded() { + fun testShowChangelogWhenNeeded() { // Arrange - every { mockedViewModel.uiState } returns - MutableStateFlow(ChangelogDialogUiState.Show(listOf(CHANGELOG_ITEM))) - every { mockedViewModel.dismissChangelogDialog() } just Runs + every { mockedViewModel.markChangelogAsRead() } just Runs composeTestRule.setContentWithTheme { ChangelogDialog( - changesList = listOf(CHANGELOG_ITEM), - version = CHANGELOG_VERSION, - onDismiss = { mockedViewModel.dismissChangelogDialog() } + Changelog( + changes = listOf(CHANGELOG_ITEM), + version = CHANGELOG_VERSION, + ), + onDismiss = { mockedViewModel.markChangelogAsRead() } ) } @@ -50,7 +49,7 @@ class ChangelogDialogTest { composeTestRule.onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick() // Assert - verify { mockedViewModel.dismissChangelogDialog() } + verify { mockedViewModel.markChangelogAsRead() } } companion object { diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 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/FilterScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt index b5f762b89b22..32fd727329fc 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.test.performClick import io.mockk.MockKAnnotations import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.flow.MutableSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.RelayFilterState import net.mullvad.mullvadvpn.model.Ownership @@ -31,8 +30,7 @@ class FilterScreenTest { selectedOwnership = null, selectedProviders = DUMMY_SELECTED_PROVIDERS, ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> } + onSelectedProvider = { _, _ -> } ) } composeTestRule.apply { @@ -51,8 +49,7 @@ class FilterScreenTest { selectedOwnership = null, selectedProviders = DUMMY_SELECTED_PROVIDERS ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> } + onSelectedProvider = { _, _ -> } ) } composeTestRule.apply { @@ -71,8 +68,7 @@ class FilterScreenTest { selectedOwnership = Ownership.MullvadOwned, selectedProviders = DUMMY_SELECTED_PROVIDERS ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> } + onSelectedProvider = { _, _ -> } ) } composeTestRule.apply { @@ -91,8 +87,7 @@ class FilterScreenTest { selectedOwnership = Ownership.Rented, selectedProviders = DUMMY_SELECTED_PROVIDERS ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> } + onSelectedProvider = { _, _ -> } ) } composeTestRule.apply { @@ -111,8 +106,7 @@ class FilterScreenTest { selectedOwnership = null, selectedProviders = DUMMY_SELECTED_PROVIDERS ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> } + onSelectedProvider = { _, _ -> } ) } @@ -135,8 +129,7 @@ class FilterScreenTest { selectedOwnership = null, selectedProviders = listOf(Provider("31173", true)) ), - uiCloseAction = MutableSharedFlow(), - onSelectedProviders = { _, _ -> }, + onSelectedProvider = { _, _ -> }, onApplyClick = mockClickListener ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt index 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 7e66bc24d9fa..ea1d26168949 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() ) } @@ -61,8 +57,6 @@ class SelectLocationScreenTest { selectedProvidersCount = 0, searchTerm = "" ), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -101,8 +95,6 @@ class SelectLocationScreenTest { selectedProvidersCount = 0, searchTerm = "" ), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -131,8 +123,6 @@ class SelectLocationScreenTest { selectedProvidersCount = 0, searchTerm = "" ), - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onSearchTermInput = mockedSearchTermInput ) } @@ -160,8 +150,6 @@ class SelectLocationScreenTest { selectedProvidersCount = 0, 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/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt index 6566a9f30e5a..d2dcf1e86386 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt @@ -47,7 +47,6 @@ fun FilterCell( Modifier.horizontalScroll(scrollState) .padding( horizontal = Dimens.searchFieldHorizontalPadding, - vertical = Dimens.selectLocationTitlePadding ) .fillMaxWidth(), ) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index 3388eb2b85d7..32c9f83a3343 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt @@ -10,24 +10,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.AnimatedIconButton -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.extension.copyToClipboard @Preview @Composable private fun PreviewCopyableObfuscationView() { - AppTheme { CopyableObfuscationView("1111222233334444", modifier = Modifier.fillMaxWidth()) } + AppTheme { CopyableObfuscationView("1111222233334444", {}, modifier = Modifier.fillMaxWidth()) } } @Composable -fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) { +fun CopyableObfuscationView( + content: String, + onCopyClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { var obfuscationEnabled by remember { mutableStateOf(true) } Row(verticalAlignment = CenterVertically, modifier = modifier) { @@ -44,19 +45,7 @@ fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) { onClick = { obfuscationEnabled = !obfuscationEnabled } ) - val context = LocalContext.current - val copy = { - context.copyToClipboard( - content = content, - clipboardLabel = context.getString(R.string.mullvad_account_number) - ) - SdkUtils.showCopyToastIfNeeded( - context, - context.getString(R.string.copied_mullvad_account_number) - ) - } - - CopyAnimatedIconButton(onClick = copy) + CopyAnimatedIconButton(onClick = { onCopyClicked(content) }) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 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..8e34ecdce4ed 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..859f28fea339 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 PreviewRemoveDeviceConfirmationDialog() { + 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..88c305b8c0cb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt @@ -5,17 +5,28 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.util.getActivity +import net.mullvad.mullvadvpn.viewmodel.PaymentUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -108,11 +119,38 @@ private fun PreviewPaymentDialogPaymentAvailabilityError() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator) { + 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) { + 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..38404ec96b75 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity +import android.os.Build import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -13,25 +13,32 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.ExternalButton import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton @@ -41,11 +48,13 @@ import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar -import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog -import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct @@ -58,6 +67,7 @@ import net.mullvad.mullvadvpn.util.toExpiryDateString import net.mullvad.mullvadvpn.viewmodel.AccountUiState import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.joda.time.DateTime +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -65,12 +75,12 @@ import org.joda.time.DateTime private fun PreviewAccountScreen() { AppTheme { AccountScreen( - showSitePayment = true, uiState = AccountUiState( deviceName = "Test Name", accountNumber = "1234123412341234", accountExpiry = null, + showSitePayment = true, billingPaymentState = PaymentState.PaymentAvailable( listOf( @@ -88,70 +98,94 @@ private fun PreviewAccountScreen() { ) ), uiSideEffect = MutableSharedFlow().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, + uiSideEffect: Flow, + 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..cbf1f53c3dc2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember @@ -24,11 +27,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.ConnectionButton import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText @@ -37,6 +40,10 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.SelectLocationDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG @@ -44,14 +51,17 @@ import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -62,16 +72,64 @@ private fun PreviewConnectScreen() { AppTheme { ConnectScreen( uiState = state, - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } } +@Destination(style = HomeTransition::class) +@Composable +fun Connect(navigator: DestinationsNavigator) { + val connectViewModel: ConnectViewModel = koinViewModel() + + val state = connectViewModel.uiState.collectAsState().value + + val context = LocalContext.current + LaunchedEffect(key1 = Unit) { + connectViewModel.uiSideEffect.collect { uiSideEffect -> + when (uiSideEffect) { + is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { + context.openAccountPageInBrowser(uiSideEffect.token) + } + is ConnectViewModel.UiSideEffect.OutOfTime -> { + navigator.navigate(OutOfTimeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + } + ConnectScreen( + uiState = state, + onDisconnectClick = connectViewModel::onDisconnectClick, + onReconnectClick = connectViewModel::onReconnectClick, + onConnectClick = connectViewModel::onConnectClick, + onCancelClick = connectViewModel::onCancelClick, + onSwitchLocationClick = { + navigator.navigate(SelectLocationDestination) { launchSingleTop = true } + }, + onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, + onUpdateVersionClick = { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + context.getString(R.string.download_url).appendHideNavOnPlayBuild() + ) + ) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + context.startActivity(intent) + }, + onManageAccountClick = connectViewModel::onManageAccountClick, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, + ) +} + @Composable fun ConnectScreen( uiState: ConnectUiState, - uiSideEffect: SharedFlow, - 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/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt index 844360c16c93..b9db62d620dd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -8,37 +7,45 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ApplyButton import net.mullvad.mullvadvpn.compose.cell.CheckboxCell import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Ownership import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.viewmodel.FilterScreenSideEffect +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -53,49 +60,63 @@ private fun PreviewFilterScreen() { FilterScreen( uiState = state, onSelectedOwnership = {}, - onSelectedProviders = { _, _ -> }, + onSelectedProvider = { _, _ -> }, onAllProviderCheckChange = {}, - uiCloseAction = MutableSharedFlow() ) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun FilterScreen(navigator: DestinationsNavigator) { + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + FilterScreenSideEffect.CloseScreen -> navigator.navigateUp() + } + } + } + FilterScreen( + uiState = uiState, + onBackClick = navigator::navigateUp, + onApplyClick = viewModel::onApplyButtonClicked, + onSelectedOwnership = viewModel::setSelectedOwnership, + onAllProviderCheckChange = viewModel::setAllProviders, + onSelectedProvider = viewModel::setSelectedProvider + ) +} + @Composable fun FilterScreen( uiState: RelayFilterState, onBackClick: () -> Unit = {}, - uiCloseAction: SharedFlow, onApplyClick: () -> Unit = {}, onSelectedOwnership: (ownership: Ownership?) -> Unit = {}, onAllProviderCheckChange: (isChecked: Boolean) -> Unit = {}, - onSelectedProviders: (checked: Boolean, provider: Provider) -> Unit + onSelectedProvider: (checked: Boolean, provider: Provider) -> Unit ) { var providerExpanded by rememberSaveable { mutableStateOf(false) } var ownershipExpanded by rememberSaveable { mutableStateOf(false) } val backgroundColor = MaterialTheme.colorScheme.background - LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } Scaffold( + modifier = Modifier.background(backgroundColor).systemBarsPadding().fillMaxSize(), topBar = { - Row( - Modifier.padding( - horizontal = Dimens.selectFilterTitlePadding, - vertical = Dimens.selectFilterTitlePadding + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + tint = Color.Unspecified, ) - .fillMaxWidth(), - ) { - Image( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, - modifier = Modifier.size(Dimens.titleIconSize).clickable(onClick = onBackClick) - ) + } Text( text = stringResource(R.string.filter), - modifier = - Modifier.align(Alignment.CenterVertically) - .weight(weight = 1f) - .padding(end = Dimens.titleIconSize), + modifier = Modifier.weight(1f).padding(end = Dimens.titleIconSize), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimary @@ -124,9 +145,7 @@ fun FilterScreen( } }, ) { contentPadding -> - LazyColumn( - modifier = Modifier.padding(contentPadding).background(backgroundColor).fillMaxSize() - ) { + LazyColumn(modifier = Modifier.padding(contentPadding).fillMaxSize()) { item { Divider() ExpandableComposeCell( @@ -178,7 +197,7 @@ fun FilterScreen( CheckboxCell( providerName = provider.name, checked = provider in uiState.selectedProviders, - onCheckedChange = { checked -> onSelectedProviders(checked, provider) } + onCheckedChange = { checked -> onSelectedProvider(checked, provider) } ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index 113ef4b020b5..4dac203fa871 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,18 +45,25 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceListDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState import net.mullvad.mullvadvpn.compose.state.LoginState.Idle @@ -64,10 +73,14 @@ import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.compose.test.LOGIN_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOGIN_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.compose.transitions.LoginTransition import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar +import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -101,9 +114,63 @@ private fun PreviewLoginSuccess() { AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } } -@OptIn(ExperimentalComposeUiApi::class) +@Destination(style = LoginTransition::class) +@Composable +fun Login( + navigator: DestinationsNavigator, + accountToken: String? = null, + vm: LoginViewModel = koinViewModel() +) { + val state by vm.uiState.collectAsState() + + // Login with argument, e.g when user comes from Too Many Devices screen + LaunchedEffect(accountToken) { + if (accountToken != null) { + vm.onAccountNumberChange(accountToken) + vm.login(accountToken) + } + } + + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + LoginUiSideEffect.NavigateToWelcome -> { + navigator.navigate(WelcomeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.TooManyDevices -> { + navigator.navigate(DeviceListDestination(it.accountToken.value)) { + launchSingleTop = true + } + } + LoginUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + LoginScreen( + state, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + vm::onAccountNumberChange, + { navigator.navigate(SettingsDestination) } + ) +} + @Composable -fun LoginScreen( +private fun LoginScreen( uiState: LoginUiState, onLoginClick: (String) -> Unit = {}, onCreateAccountClick: () -> Unit = {}, @@ -112,13 +179,11 @@ fun LoginScreen( onSettingsClick: () -> Unit = {}, ) { ScaffoldWithTopBar( - modifier = Modifier.semantics { testTagsAsResourceId = true }, topBarColor = MaterialTheme.colorScheme.primary, - statusBarColor = MaterialTheme.colorScheme.primary, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - onAccountClicked = null + enabled = uiState.loginState is Idle, + onAccountClicked = null, ) { val scrollState = rememberScrollState() Column( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt new file mode 100644 index 000000000000..8ef535a58b69 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -0,0 +1,77 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.navigation.NavHostController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.navigate +import com.ramcosta.composedestinations.navigation.popBackStack +import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.utils.destination +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.compose.NavGraphs +import net.mullvad.mullvadvpn.compose.destinations.ChangelogDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel +import org.koin.androidx.compose.koinViewModel + +private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MullvadApp() { + val engine = rememberNavHostEngine() + val navController: NavHostController = engine.rememberNavController() + + val serviceVm = koinViewModel() + + DisposableEffect(Unit) { + navController.addOnDestinationChangedListener(serviceVm) + onDispose { navController.removeOnDestinationChangedListener(serviceVm) } + } + + DestinationsNavHost( + modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), + engine = engine, + navController = navController, + navGraph = NavGraphs.root + ) + + // Globally handle daemon dropped connection with NoDaemonScreen + LaunchedEffect(Unit) { + serviceVm.uiSideEffect.collect { + when (it) { + DaemonScreenEvent.Show -> + navController.navigate(NoDaemonScreenDestination) { launchSingleTop = true } + DaemonScreenEvent.Remove -> + navController.popBackStack(NoDaemonScreenDestination, true) + } + } + } + + // Globally show the changelog + val changeLogsViewModel = koinViewModel() + LaunchedEffect(Unit) { + changeLogsViewModel.uiSideEffect.collect { + + // Wait until we are in an acceptable destination + navController.currentBackStackEntryFlow + .map { it.destination() } + .first { it in changeLogDestinations } + + navController.navigate(ChangelogDestination(it).route) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt similarity index 65% 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 022ca06c53a6..af47b37fc258 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 @@ -15,33 +16,47 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.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 net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.util.getActivity @Preview @Composable -private fun PreviewLoadingScreen() { - AppTheme { LoadingScreen() } +private fun PreviewNoDaemonScreen() { + AppTheme { NoDaemonScreen({}) } } +// Set this as the start destination of the default nav graph +@Destination(style = DefaultTransition::class) @Composable -fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { +fun NoDaemonScreen(navigator: DestinationsNavigator) { + NoDaemonScreen { navigator.navigate(SettingsDestination) } +} + +@Composable +fun NoDaemonScreen(onNavigateToSettings: () -> Unit) { + val backgroundColor = MaterialTheme.colorScheme.primary + val context = LocalContext.current + BackHandler { finishAffinity(context.getActivity()!!) } + ScaffoldWithTopBar( topBarColor = backgroundColor, - statusBarColor = backgroundColor, - navigationBarColor = backgroundColor, - onSettingsClicked = onSettingsCogClicked, + onSettingsClicked = onNavigateToSettings, onAccountClicked = null, isIconAndLogoVisible = false, content = { @@ -60,23 +75,26 @@ fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { Image( painter = painterResource(id = R.drawable.launch_logo), contentDescription = "", - modifier = Modifier.size(120.dp) + modifier = Modifier.size(Dimens.splashLogoSize) ) Image( painter = painterResource(id = R.drawable.logo_text), contentDescription = "", alpha = 0.6f, - modifier = Modifier.padding(top = 12.dp).height(18.dp) + modifier = + Modifier.padding(top = Dimens.mediumPadding) + .height(Dimens.splashLogoTextHeight) ) Text( text = stringResource(id = R.string.connecting_to_daemon), - fontSize = 13.sp, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary .copy(alpha = AlphaDescription) .compositeOver(backgroundColor), modifier = - Modifier.padding(top = 12.dp).padding(horizontal = Dimens.sideMargin), + Modifier.padding(top = Dimens.mediumPadding) + .padding(horizontal = Dimens.sideMargin), textAlign = TextAlign.Center ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index b7b4744bb29f..d9071be7d85e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -15,31 +14,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -50,15 +53,19 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewOutOfTimeScreenDisconnected() { AppTheme { OutOfTimeScreen( - showSitePayment = true, - uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected, "Heroic Frog"), - uiSideEffect = MutableSharedFlow().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 = HomeTransition::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, + redeemVoucherResultRecipient: ResultRecipient, + playPaymentResultRecipient: ResultRecipient ) { - val context = LocalContext.current + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + redeemVoucherResultRecipient.onNavResult { + // If we successfully redeemed a voucher, navigate to Connect screen + if (it is NavResult.Value && it.value) { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + + playPaymentResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) + } + } + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() - LaunchedEffect(key1 = Unit) { - uiSideEffect.collect { uiSideEffect -> + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { uiSideEffect -> when (uiSideEffect) { is OutOfTimeViewModel.UiSideEffect.OpenAccountView -> openAccountPage(uiSideEffect.token) - OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> openConnectScreen() + OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } } } } - var showVerificationPendingDialog by remember { mutableStateOf(false) } - if (showVerificationPendingDialog) { - VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) - } + OutOfTimeScreen( + uiState = state, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + onDisconnectClick = vm::onDisconnectClick, + onPurchaseBillingProductClick = { productId -> + navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true } + }, + navigateToVerificationPendingDialog = { + navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true } + } + ) +} - uiState.paymentDialogData?.let { - PaymentDialog( - paymentDialogData = uiState.paymentDialogData, - retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } }, - onCloseDialog = onClosePurchaseResultDialog - ) - } +@Composable +fun OutOfTimeScreen( + uiState: OutOfTimeUiState, + onDisconnectClick: () -> Unit = {}, + onSitePaymentClick: () -> Unit = {}, + onRedeemVoucherClick: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, + onAccountClick: () -> Unit = {}, + onPurchaseBillingProductClick: (ProductId) -> Unit = { _ -> }, + navigateToVerificationPendingDialog: () -> Unit = {} +) { val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( @@ -143,13 +185,6 @@ fun OutOfTimeScreen( } else { MaterialTheme.colorScheme.error }, - statusBarColor = - if (uiState.tunnelState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = if (uiState.tunnelState.isSecured()) { MaterialTheme.colorScheme.onPrimary @@ -191,7 +226,7 @@ fun OutOfTimeScreen( text = buildString { append(stringResource(R.string.account_credit_has_expired)) - if (showSitePayment) { + if (uiState.showSitePayment) { append(" ") append(stringResource(R.string.add_time_to_account)) } @@ -223,9 +258,9 @@ fun OutOfTimeScreen( PlayPayment( billingPaymentState = uiState.billingPaymentState, onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) { context as Activity } + onPurchaseBillingProductClick(productId) }, - onInfoClick = { showVerificationPendingDialog = true }, + onInfoClick = navigateToVerificationPendingDialog, modifier = Modifier.padding( start = Dimens.sideMargin, @@ -235,7 +270,7 @@ fun OutOfTimeScreen( .align(Alignment.CenterHorizontally) ) } - if (showSitePayment) { + if (uiState.showSitePayment) { SitePaymentButton( onClick = onSitePaymentClick, isEnabled = uiState.tunnelState.enableSitePaymentButton(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index 02250c36632f..b57e66c1518e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -16,9 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -30,14 +31,24 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition import net.mullvad.mullvadvpn.compose.util.toDp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -45,24 +56,41 @@ private fun PreviewPrivacyDisclaimerScreen() { AppTheme { PrivacyDisclaimerScreen({}, {}) } } +@Destination(style = DefaultTransition::class) +@Composable +fun PrivacyDisclaimer( + navigator: DestinationsNavigator, +) { + val viewModel: PrivacyDisclaimerViewModel = koinViewModel() + + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + PrivacyDisclaimerUiSideEffect.NavigateToLogin -> { + (context as MainActivity).initializeStateHandlerAndServiceConnection() + navigator.navigate(LoginDestination(null)) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + } + PrivacyDisclaimerScreen({}, viewModel::setPrivacyDisclosureAccepted) +} + @Composable fun PrivacyDisclaimerScreen( onPrivacyPolicyLinkClicked: () -> Unit, onAcceptClicked: () -> Unit, ) { val topColor = MaterialTheme.colorScheme.primary - ScaffoldWithTopBar( - topBarColor = topColor, - statusBarColor = topColor, - navigationBarColor = MaterialTheme.colorScheme.background, - onAccountClicked = null, - onSettingsClicked = null - ) { + ScaffoldWithTopBar(topBarColor = topColor, onAccountClicked = null, onSettingsClicked = null) { ConstraintLayout( modifier = - Modifier.fillMaxHeight() - .fillMaxWidth() - .padding(it) + Modifier.padding(it) + .fillMaxSize() .background(color = MaterialTheme.colorScheme.background) ) { val (body, actionButtons) = createRefs() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/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..4763b2199718 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 5bfdee94f614..d113ca258dfa 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -1,13 +1,10 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,17 +15,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -38,9 +32,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.core.text.HtmlCompat -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterCell import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell @@ -48,15 +41,20 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.textfield.SearchTextField +import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -72,18 +70,37 @@ private fun PreviewSelectLocationScreen() { AppTheme { SelectLocationScreen( uiState = state, - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } -@OptIn(ExperimentalComposeUiApi::class) +@Destination(style = SelectLocationTransition::class) +@Composable +fun SelectLocation(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() + } + } + } + + SelectLocationScreen( + uiState = state, + onSelectRelay = vm::selectRelay, + onSearchTermInput = vm::onSearchTermInput, + onBackClick = navigator::navigateUp, + onFilterClick = { navigator.navigate(FilterScreenDestination) }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter + ) +} + @Composable fun SelectLocationScreen( uiState: SelectLocationUiState, - uiCloseAction: SharedFlow, - enterTransitionEndAction: SharedFlow, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, @@ -91,143 +108,131 @@ fun SelectLocationScreen( removeOwnershipFilter: () -> Unit = {}, removeProviderFilter: () -> Unit = {} ) { - val backgroundColor = MaterialTheme.colorScheme.background - val systemUiController = rememberSystemUiController() - LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } - LaunchedEffect(Unit) { - enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) } - } - - val (backFocus, listFocus, searchBarFocus) = remember { FocusRequester.createRefs() } - Column(modifier = Modifier.background(backgroundColor).fillMaxWidth().fillMaxHeight()) { - Row( - modifier = - Modifier.padding(vertical = Dimens.selectLocationTitlePadding) - .padding(end = Dimens.selectLocationTitlePadding) - .fillMaxWidth() - ) { - IconButton(onClick = onBackClick) { - Icon( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(Dimens.titleIconSize).rotate(270f) + Scaffold { + Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onBackClick) { + Icon( + modifier = Modifier.rotate(270f), + painter = painterResource(id = R.drawable.icon_back), + tint = Color.Unspecified, + contentDescription = null, + ) + } + Text( + text = stringResource(id = R.string.select_location), + modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary ) - } - Text( - text = stringResource(id = R.string.select_location), - modifier = - Modifier.align(Alignment.CenterVertically) - .weight(weight = 1f) - .padding(end = Dimens.titleIconSize), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimary - ) - Image( - painter = painterResource(id = R.drawable.icons_more_circle), - contentDescription = null, - modifier = Modifier.size(Dimens.titleIconSize).clickable { onFilterClick() } - ) - } - when (uiState) { - SelectLocationUiState.Loading -> {} - is SelectLocationUiState.ShowData -> { - if (uiState.hasFilter) { - FilterCell( - ownershipFilter = uiState.selectedOwnership, - selectedProviderFilter = uiState.selectedProvidersCount, - removeOwnershipFilter = removeOwnershipFilter, - removeProviderFilter = removeProviderFilter + IconButton(onClick = onFilterClick) { + Icon( + painter = painterResource(id = R.drawable.icons_more_circle), + contentDescription = null, + tint = Color.Unspecified, ) } } - } - SearchTextField( - modifier = - Modifier.fillMaxWidth() - .focusRequester(searchBarFocus) - .focusProperties { next = backFocus } - .height(Dimens.searchFieldHeight) - .padding(horizontal = Dimens.searchFieldHorizontalPadding) - ) { searchString -> - onSearchTermInput.invoke(searchString) - } - Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = - Modifier.focusRequester(listFocus) - .fillMaxSize() - .drawVerticalScrollbar( - lazyListState, - MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) - ), - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - ) { when (uiState) { - SelectLocationUiState.Loading -> { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge( - Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + SelectLocationUiState.Loading -> {} + is SelectLocationUiState.ShowData -> { + if (uiState.hasFilter) { + FilterCell( + ownershipFilter = uiState.selectedOwnership, + selectedProviderFilter = uiState.selectedProvidersCount, + removeOwnershipFilter = removeOwnershipFilter, + removeProviderFilter = removeProviderFilter ) } } - is SelectLocationUiState.ShowData -> { - if (uiState.countries.isEmpty()) { - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - uiState.searchTerm + } + + SearchTextField( + modifier = + Modifier.fillMaxWidth() + .height(Dimens.searchFieldHeight) + .padding(horizontal = Dimens.searchFieldHorizontalPadding) + ) { searchString -> + onSearchTermInput.invoke(searchString) + } + Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = + Modifier.fillMaxSize() + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (uiState) { + SelectLocationUiState.Loading -> { + item(contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge( + Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } + } + is SelectLocationUiState.ShowData -> { + if (uiState.countries.isEmpty()) { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( + textResource( + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = + textResource( + id = R.string.select_location_empty_text_second_row + ) + Column( + modifier = + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding ), - HtmlCompat.FROM_HTML_MODE_COMPACT + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - val secondRow = - textResource(id = R.string.select_location_empty_text_second_row) - Column( - modifier = - Modifier.padding( - horizontal = Dimens.selectLocationTitlePadding - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = firstRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary + ) + } + } + } else { + items( + count = uiState.countries.size, + key = { index -> uiState.countries[index].hashCode() }, + contentType = { ContentType.ITEM } + ) { index -> + val country = uiState.countries[index] + RelayLocationCell( + relay = country, + selectedItem = uiState.selectedRelay, + onSelectRelay = onSelectRelay, + modifier = Modifier.animateContentSize() ) } } - } else { - items( - count = uiState.countries.size, - key = { index -> uiState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = uiState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = uiState.selectedRelay, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 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..0252c8129db9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -0,0 +1,139 @@ +package net.mullvad.mullvadvpn.compose.screen + +import android.window.SplashScreen +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceRevokedDestination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.viewmodel.SplashUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewLoadingScreen() { + AppTheme { SplashScreen() } +} + +// Set this as the start destination of the default nav graph +@RootNavGraph(start = true) +@Destination(style = DefaultTransition::class) +@Composable +fun Splash(navigator: DestinationsNavigator) { + val viewModel: SplashViewModel = koinViewModel() + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + SplashUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToLogin -> { + navigator.navigate(LoginDestination()) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToPrivacyDisclaimer -> { + navigator.navigate(PrivacyDisclaimerDestination) { popUpTo(NavGraphs.root) {} } + } + SplashUiSideEffect.NavigateToRevoked -> { + navigator.navigate(DeviceRevokedDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + + LaunchedEffect(Unit) { viewModel.start() } + + SplashScreen() +} + +@Composable +fun SplashScreen() { + + val backgroundColor = MaterialTheme.colorScheme.primary + + ScaffoldWithTopBar( + topBarColor = backgroundColor, + onSettingsClicked = null, + onAccountClicked = null, + isIconAndLogoVisible = false, + content = { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.background(backgroundColor) + .padding(it) + .padding(bottom = it.calculateTopPadding()) + .fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.launch_logo), + contentDescription = "", + modifier = Modifier.size(Dimens.splashLogoSize) + ) + Image( + painter = painterResource(id = R.drawable.logo_text), + contentDescription = "", + alpha = 0.6f, + modifier = + Modifier.padding(top = Dimens.mediumPadding) + .height(Dimens.splashLogoTextHeight) + ) + Text( + text = stringResource(id = R.string.connecting_to_daemon), + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDescription) + .compositeOver(backgroundColor), + modifier = Modifier.padding(top = Dimens.mediumPadding) + ) + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index 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..765975a446dd 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.onDnsDialogDismissed() + } + is NavResult.Value -> {} } } - var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } - val biggerPadding = 54.dp - val topPadding = 6.dp + customWgPortResult.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> { + val port = it.value - LaunchedEffect(uiState.selectedWireguardPort) { - if ( - uiState.selectedWireguardPort.isCustom() && - uiState.selectedWireguardPort != savedCustomPort.value - ) { - savedCustomPort.value = uiState.selectedWireguardPort + if (port != null) { + vm.onWireguardPortSelected(Constraint.Only(Port(port))) + } else { + vm.resetCustomPort() + } + } } } - val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { - toastMessagesSharedFlow.distinctUntilChanged().collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + vm.uiSideEffect.collect { + when (it) { + is VpnSettingsSideEffect.ShowToast -> + launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = it.message) + } + VpnSettingsSideEffect.NavigateToDnsDialog -> + navigator.navigate(DnsDialogDestination(null, null)) { launchSingleTop = true } + } } } + + val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onStopEvent() + vm.onStopEvent() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + + VpnSettingsScreen( + uiState = state, + snackbarHostState = snackbarHostState, + navigateToContentBlockersInfo = { + navigator.navigate(ContentBlockersInfoDialogDestination) { launchSingleTop = true } + }, + navigateToCustomDnsInfo = { + navigator.navigate(CustomDnsInfoDialogDestination) { launchSingleTop = true } + }, + navigateToMalwareInfo = { + navigator.navigate(MalwareInfoDialogDestination) { launchSingleTop = true } + }, + navigateToObfuscationInfo = { + navigator.navigate(ObfuscationInfoDialogDestination) { launchSingleTop = true } + }, + navigateToQuantumResistanceInfo = { + navigator.navigate(QuantumResistanceInfoDialogDestination) { launchSingleTop = true } + }, + navigateUdp2TcpInfo = { + navigator.navigate(UdpOverTcpPortInfoDialogDestination) { launchSingleTop = true } + }, + navigateToWireguardPortInfo = { + navigator.navigate( + WireguardPortInfoDialogDestination(WireguardPortInfoDialogArgument(it)) + ) { + launchSingleTop = true + } + }, + navigateToLocalNetworkSharingInfo = { + navigator.navigate(LocalNetworkSharingInfoDialogDestination) { launchSingleTop = true } + }, + onToggleBlockTrackers = vm::onToggleBlockTrackers, + onToggleBlockAds = vm::onToggleBlockAds, + onToggleBlockMalware = vm::onToggleBlockMalware, + onToggleAutoConnect = vm::onToggleAutoConnect, + onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing, + onToggleBlockAdultContent = vm::onToggleBlockAdultContent, + onToggleBlockGambling = vm::onToggleBlockGambling, + onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia, + navigateToMtuDialog = { + navigator.navigate(MtuDialogDestination(it)) { launchSingleTop = true } + }, + navigateToDns = { index, address -> + navigator.navigate(DnsDialogDestination(index, address)) { launchSingleTop = true } + }, + navigateToWireguardPortDialog = { + val args = + WireguardCustomPortNavArgs( + state.customWireguardPort?.toValueOrNull(), + state.availablePortRanges + ) + navigator.navigate(WireguardCustomPortDialogDestination(args)) { + launchSingleTop = true + } + }, + onToggleDnsClick = vm::onToggleCustomDns, + onBackClick = navigator::navigateUp, + onSelectObfuscationSetting = vm::onSelectObfuscationSetting, + onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, + onWireguardPortSelected = vm::onWireguardPortSelected, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VpnSettingsScreen( + uiState: VpnSettingsUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + navigateToContentBlockersInfo: () -> Unit = {}, + navigateToCustomDnsInfo: () -> Unit = {}, + navigateToMalwareInfo: () -> Unit = {}, + navigateToObfuscationInfo: () -> Unit = {}, + navigateToQuantumResistanceInfo: () -> Unit = {}, + navigateUdp2TcpInfo: () -> Unit = {}, + navigateToWireguardPortInfo: (availablePortRanges: List) -> 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 4778756648eb..cc8cf4977c70 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,10 +18,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -30,21 +28,29 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser @@ -57,13 +63,13 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.color.MullvadWhite import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewWelcomeScreen() { AppTheme { WelcomeScreen( - showSitePayment = true, uiState = WelcomeUiState( accountNumber = "4444555566667777", @@ -76,55 +82,98 @@ private fun PreviewWelcomeScreen() { ) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - openConnectScreen = {}, - onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onPurchaseBillingProductClick = { _ -> }, + navigateToDeviceInfoDialog = {}, + navigateToVerificationPendingDialog = {} ) } } +@Destination(style = HomeTransition::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)) { launchSingleTop = true } + }, + navigateToVerificationPendingDialog = { + navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true } + } + ) +} +@Composable +fun WelcomeScreen( + uiState: WelcomeUiState, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onSettingsClick: () -> Unit, + onAccountClick: () -> Unit, + onPurchaseBillingProductClick: (productId: ProductId) -> Unit, + navigateToDeviceInfoDialog: () -> Unit, + navigateToVerificationPendingDialog: () -> Unit +) { val scrollState = rememberScrollState() val snackbarHostState = remember { SnackbarHostState() } @@ -135,13 +184,6 @@ fun WelcomeScreen( } else { MaterialTheme.colorScheme.error }, - statusBarColor = - if (uiState.tunnelState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = if (uiState.tunnelState.isSecured()) { MaterialTheme.colorScheme.onPrimary @@ -165,18 +207,18 @@ fun WelcomeScreen( .background(color = MaterialTheme.colorScheme.primary) ) { // Welcome info area - WelcomeInfo(snackbarHostState, uiState, showSitePayment) + WelcomeInfo(snackbarHostState, uiState, navigateToDeviceInfoDialog) Spacer(modifier = Modifier.weight(1f)) // Payment button area PaymentPanel( - showSitePayment = showSitePayment, + showSitePayment = uiState.showSitePayment, billingPaymentState = uiState.billingPaymentState, onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onPaymentInfoClick = { showVerificationPendingDialog = true } + onPaymentInfoClick = navigateToVerificationPendingDialog ) } } @@ -186,7 +228,7 @@ fun WelcomeScreen( private fun WelcomeInfo( snackbarHostState: SnackbarHostState, uiState: WelcomeUiState, - showSitePayment: Boolean + navigateToDeviceInfoDialog: () -> Unit ) { Column { Text( @@ -217,13 +259,13 @@ private fun WelcomeInfo( AccountNumberRow(snackbarHostState, uiState) - DeviceNameRow(deviceName = uiState.deviceName) + DeviceNameRow(deviceName = uiState.deviceName, navigateToDeviceInfoDialog) Text( text = buildString { append(stringResource(id = R.string.pay_to_start_using)) - if (showSitePayment) { + if (uiState.showSitePayment) { append(" ") append(stringResource(id = R.string.add_time_to_account)) } @@ -269,7 +311,7 @@ private fun AccountNumberRow(snackbarHostState: SnackbarHostState, uiState: Welc } @Composable -fun DeviceNameRow(deviceName: String?) { +fun DeviceNameRow(deviceName: String?, navigateToDeviceInfoDialog: () -> Unit) { Row( modifier = Modifier.padding(horizontal = Dimens.sideMargin), verticalAlignment = Alignment.CenterVertically, @@ -288,10 +330,9 @@ fun DeviceNameRow(deviceName: String?) { color = MaterialTheme.colorScheme.onPrimary ) - var showDeviceNameDialog by remember { mutableStateOf(false) } IconButton( modifier = Modifier.align(Alignment.CenterVertically), - onClick = { showDeviceNameDialog = true } + onClick = navigateToDeviceInfoDialog ) { Icon( painter = painterResource(id = R.drawable.icon_info), @@ -299,9 +340,6 @@ fun DeviceNameRow(deviceName: String?) { tint = MullvadWhite ) } - if (showDeviceNameDialog) { - DeviceNameInfoDialog { showDeviceNameDialog = false } - } } } @@ -311,10 +349,9 @@ private fun PaymentPanel( billingPaymentState: PaymentState?, onSitePaymentClick: () -> Unit, onRedeemVoucherClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit, + onPurchaseBillingProductClick: (productId: ProductId) -> Unit, onPaymentInfoClick: () -> Unit ) { - val context = LocalContext.current Column( modifier = Modifier.fillMaxWidth() @@ -326,7 +363,7 @@ private fun PaymentPanel( PlayPayment( billingPaymentState = billingPaymentState, onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) { context as Activity } + onPurchaseBillingProductClick(productId) }, onInfoClick = onPaymentInfoClick, modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt index 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/DefaultTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt new file mode 100644 index 000000000000..4c02b278d0f3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object DefaultTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope.exitTransition() = fadeOut() + + override fun AnimatedContentTransitionScope.popEnterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt new file mode 100644 index 000000000000..93c94ecd8739 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS + +// This is used for OutOfTime, Welcome, and Connect destinations. +object HomeTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + when (this.initialState.destination()) { + is LoginDestination -> fadeIn() + else -> EnterTransition.None + } + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope.exitTransition() = + fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + EnterTransition.None + + override fun AnimatedContentTransitionScope.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt new file mode 100644 index 000000000000..162dacbd9066 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS + +object LoginTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = fadeIn() + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope.exitTransition() = + when (this.targetState.destination()) { + is OutOfTimeDestination, + is WelcomeDestination, + is ConnectDestination -> fadeOut() + else -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt new file mode 100644 index 000000000000..75fb7286fc57 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SettingsTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() }) + } + + 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..da802483b523 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SlideInFromBottomTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> fadeOut() + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> fadeIn() + } + + override fun AnimatedContentTransitionScope.popExitTransition() = + slideOutVertically(targetOffsetY = { it }) +} + +object SelectLocationTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally { -it.withHorizontalScalingFactor() } + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally { -it.withHorizontalScalingFactor() } + } + + override fun AnimatedContentTransitionScope.popExitTransition() = + slideOutVertically(targetOffsetY = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt new file mode 100644 index 000000000000..69baa8eb4796 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SlideInFromRightTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInHorizontally(initialOffsetX = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope.popExitTransition() = + slideOutHorizontally(targetOffsetX = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt new file mode 100644 index 000000000000..4ccf15bb63f3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.constant + +import androidx.compose.animation.core.Spring + +const val MINIMUM_LOADING_TIME_MILLIS = 500L + +const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt() + +const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f + +fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt deleted file mode 100644 index 2a09964a809d..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.constant - -const val MINIMUM_LOADING_TIME_MILLIS = 500L 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 9e35e67823a6..e12e3e232238 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.PortRangeUseCase @@ -41,13 +42,18 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel @@ -101,6 +107,7 @@ val uiModule = module { single { NewDeviceNotificationUseCase(get()) } single { PortRangeUseCase(get()) } single { RelayListUseCase(get(), get()) } + single { OutOfTimeUseCase(get(), get()) } single { ConnectivityUseCase(get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -129,20 +136,29 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } + viewModel { MtuDialogViewModel(get()) } + viewModel { parameters -> + DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull()) + } viewModel { LoginViewModel(get(), get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } - viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } - viewModel { WelcomeViewModel(get(), get(), get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } + viewModel { WelcomeViewModel(get(), get(), get(), get(), get()) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } - viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get()) } + viewModel { PaymentViewModel(get()) } viewModel { FilterViewModel(get()) } + + // This view model must be single so we correctly attach lifecycle and share it with activity + single { NoDaemonViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt index d1f395d387f3..b68a93bc648d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt @@ -4,11 +4,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -29,16 +27,11 @@ class AccountRepository( private val messageHandler: MessageHandler, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - private val _cachedCreatedAccount = MutableStateFlow(null) - val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow() - private val accountCreationEvents: SharedFlow = messageHandler .events() .map { it.result } - .onEach { - _cachedCreatedAccount.value = (it as? AccountCreationResult.Success)?.accountToken - } + .onEach {} .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) val accountExpiryState: StateFlow = @@ -75,7 +68,6 @@ class AccountRepository( } fun logout() { - clearCreatedAccountCache() messageHandler.trySendRequest(Request.Logout) } @@ -90,8 +82,4 @@ class AccountRepository( fun clearAccountHistory() { messageHandler.trySendRequest(Request.ClearAccountHistory) } - - fun clearCreatedAccountCache() { - _cachedCreatedAccount.value = null - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt index 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..81c4b85b882c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -39,16 +39,38 @@ class SettingsRepository( dnsList: List, contentBlockersOptions: DefaultDnsOptions ) { - serviceConnectionManager - .customDns() - ?.setDnsOptions( - dnsOptions = - DnsOptions( - state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, - customOptions = CustomDnsOptions(ArrayList(dnsList)), - defaultOptions = contentBlockersOptions + updateDnsSettings { + DnsOptions( + state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, + customOptions = CustomDnsOptions(ArrayList(dnsList)), + defaultOptions = contentBlockersOptions + ) + } + } + + fun setDnsState( + state: DnsState, + ) { + updateDnsSettings { it.copy(state = state) } + } + + fun updateCustomDnsList(update: (List) -> 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?) { 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 f5e24dacf187..e0ee6cdd2128 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -2,127 +2,69 @@ package net.mullvad.mullvadvpn.ui import android.Manifest import android.app.Activity -import android.app.UiModeManager import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.res.Configuration import android.net.VpnService import android.os.Bundle import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.BuildConfig -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog +import androidx.core.view.WindowCompat +import net.mullvad.mullvadvpn.compose.screen.MullvadApp import net.mullvad.mullvadvpn.di.paymentModule import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted -import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository -import net.mullvad.mullvadvpn.ui.fragment.AccountFragment -import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment -import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment -import net.mullvad.mullvadvpn.ui.fragment.FilterFragment -import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment -import net.mullvad.mullvadvpn.ui.fragment.LoginFragment -import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment -import net.mullvad.mullvadvpn.ui.fragment.PrivacyDisclaimerFragment -import net.mullvad.mullvadvpn.ui.fragment.SettingsFragment -import net.mullvad.mullvadvpn.ui.fragment.WelcomeFragment import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS -import net.mullvad.mullvadvpn.util.addDebounceForUnknownState -import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -open class MainActivity : FragmentActivity() { +class MainActivity : ComponentActivity() { private val requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // NotificationManager.areNotificationsEnabled is used to check the state rather than // handling the callback value. } - private val deviceIsTv by lazy { - val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager - - uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION - } - private lateinit var accountRepository: AccountRepository private lateinit var deviceRepository: DeviceRepository private lateinit var privacyDisclaimerRepository: PrivacyDisclaimerRepository private lateinit var serviceConnectionManager: ServiceConnectionManager private lateinit var changelogViewModel: ChangelogViewModel - - private var deviceStateJob: Job? = null - private var currentDeviceState: DeviceState? = null + private lateinit var serviceConnectionViewModel: NoDaemonViewModel override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(listOf(uiModule, paymentModule)) + // Tell the system that we will draw behind the status bar and navigation bar + WindowCompat.setDecorFitsSystemWindows(window, false) + getKoin().apply { accountRepository = get() deviceRepository = get() privacyDisclaimerRepository = get() serviceConnectionManager = get() changelogViewModel = get() + serviceConnectionViewModel = get() } - - requestedOrientation = - if (deviceIsTv) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } + lifecycle.addObserver(serviceConnectionViewModel) super.onCreate(savedInstanceState) - setContentView(R.layout.main) - } - - override fun onStart() { - Log.d("mullvad", "Starting main activity") - super.onStart() - - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - } else { - openPrivacyDisclaimerFragment() - } + setContent { AppTheme { MullvadApp() } } } - fun initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - deviceStateJob = launchDeviceStateHandler() + fun initializeStateHandlerAndServiceConnection() { checkForNotificationPermission() serviceConnectionManager.bind( vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = apiEndpointConfiguration + apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() ) } @@ -130,6 +72,14 @@ open class MainActivity : FragmentActivity() { serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK) } + override fun onStart() { + super.onStart() + + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + initializeStateHandlerAndServiceConnection() + } + } + override fun onStop() { Log.d("mullvad", "Stopping main activity") super.onStop() @@ -137,111 +87,14 @@ open class MainActivity : FragmentActivity() { // NOTE: `super.onStop()` must be called before unbinding due to the fragment state handling // otherwise the fragments will believe there was an unexpected disconnect. serviceConnectionManager.unbind() - - deviceStateJob?.cancel() } override fun onDestroy() { serviceConnectionManager.onDestroy() + lifecycle.removeObserver(serviceConnectionViewModel) super.onDestroy() } - fun openAccount() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, AccountFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - fun openSettings() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, SettingsFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - fun openFilter() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, FilterFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun launchDeviceStateHandler(): Job { - return lifecycleScope.launch { - launch { - deviceRepository.deviceState - .debounce { - // Debounce DeviceState.Unknown to delay view transitions during reconnect. - it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) - } - .collect { newState -> - if (newState != currentDeviceState) - when (newState) { - is DeviceState.Initial, - is DeviceState.Unknown -> openLaunchView() - is DeviceState.LoggedOut -> openLoginView() - is DeviceState.Revoked -> openRevokedView() - is DeviceState.LoggedIn -> { - openLoggedInView( - accountToken = newState.accountAndDevice.account_token, - shouldDelayLogin = - currentDeviceState is DeviceState.LoggedOut - ) - } - } - currentDeviceState = newState - } - } - - lifecycleScope.launch { - deviceRepository.deviceState - .filter { it is DeviceState.LoggedIn || it is DeviceState.LoggedOut } - .collect { loadChangelogComponent() } - } - } - } - - private fun loadChangelogComponent() { - findViewById(R.id.compose_view).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) - setContent { - val state = changelogViewModel.uiState.collectAsState().value - if (state is ChangelogDialogUiState.Show) { - AppTheme { - ChangelogDialog( - changesList = state.changes, - version = BuildConfig.VERSION_NAME, - onDismiss = { changelogViewModel.dismissChangelogDialog() } - ) - } - } - } - changelogViewModel.refreshChangelogDialogUiState() - } - } - @Suppress("DEPRECATION") private fun requestVpnPermission() { val intent = VpnService.prepare(this) @@ -249,97 +102,9 @@ open class MainActivity : FragmentActivity() { startActivityForResult(intent, 0) } - private fun openLaunchView() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoadingFragment()) - commitAllowingStateLoss() - } - } - - private fun openPrivacyDisclaimerFragment() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, PrivacyDisclaimerFragment()) - commitAllowingStateLoss() - } - } - - private suspend fun openLoggedInView(accountToken: String, shouldDelayLogin: Boolean) { - val isNewAccount = accountToken == accountRepository.cachedCreatedAccount.value - val isExpired = isNewAccount.not() && isExpired(LOGIN_AWAIT_EXPIRY_MILLIS) - - val fragment = - when { - isNewAccount -> WelcomeFragment() - isExpired -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - OutOfTimeFragment() - } - else -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - ConnectFragment() - } - } - - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, fragment) - commitAllowingStateLoss() - } - } - - private suspend fun isExpired(timeoutMillis: Long): Boolean { - return withTimeoutOrNull(timeoutMillis) { - accountRepository.accountExpiryState - .onSubscription { accountRepository.fetchAccountExpiry() } - .filter { it is AccountExpiry.Available } - .map { it.date()?.isBeforeNow } - .first() - } - ?: false - } - - private fun openLoginView() { - clearBackStack() - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoginFragment()) - commitAllowingStateLoss() - } - } - - private fun openRevokedView() { - clearBackStack() - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, DeviceRevokedFragment()) - commitAllowingStateLoss() - } - } - - fun clearBackStack() { - supportFragmentManager.apply { - if (backStackEntryCount > 0) { - val firstEntry = getBackStackEntryAt(0) - popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - } - } - private fun checkForNotificationPermission() { if (isNotificationPermissionGranted().not()) { requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } - - companion object { - private const val LOGIN_DELAY_MILLIS = 1000L - private const val LOGIN_AWAIT_EXPIRY_MILLIS = 1000L - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/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/FilterFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt deleted file mode 100644 index 17357d8cbfd4..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.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.FilterScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.FilterViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class FilterFragment : Fragment() { - - 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 - FilterScreen( - uiState = state, - onSelectedOwnership = vm::setSelectedOwnership, - onAllProviderCheckChange = vm::setAllProviders, - onSelectedProviders = vm::setSelectedProvider, - uiCloseAction = vm.uiSideEffect, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - onApplyClick = vm::onApplyButtonClicked - ) - } - } - } - } -} 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 index 64fdee71f625..8b137891791f 100644 --- 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 @@ -1,52 +1 @@ -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.ui.MainActivity -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() }, - removeOwnershipFilter = vm::removeOwnerFilter, - removeProviderFilter = vm::removeProviderFilter, - onFilterClick = ::openFilterView - ) - } - } - } - } - - private fun openFilterView() { - (context as? MainActivity)?.openFilter() - } - - 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..ba7ce83172af --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt @@ -0,0 +1,77 @@ +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 accountRefreshIntervalMillis = 60L * 1000L // 1 minute +const val bufferTimeMillis = 60L * 1000L // 1 minute + +class OutOfTimeUseCase( + private val repository: AccountRepository, + private val messageHandler: MessageHandler +) { + + fun isOutOfTime(): Flow = + combine(pastAccountExpiry(), isTunnelBlockedBecauseOutOfTime()) { + accountExpiryHasPast, + tunnelOutOfTime -> + reduce(accountExpiryHasPast, tunnelOutOfTime) + } + .distinctUntilChanged() + + private fun reduce(vararg outOfTimeProperty: Boolean?): Boolean? = + when { + // If any advertises as out of time + outOfTimeProperty.any { it == true } -> true + // If all advertise as not out of time + outOfTimeProperty.all { it == false } -> false + // If some are unknown + else -> null + } + + 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.map { + if (it is AccountExpiry.Available) { + it.date() + } else { + null + } + }, + timeFlow() + ) { expiryDate, time -> + expiryDate?.isBefore(time.plus(bufferTimeMillis)) + } + + private fun timeFlow() = flow { + while (true) { + emit(DateTime.now()) + delay(accountRefreshIntervalMillis) + } + } +} 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 9be4b13b593b..39cee0342a87 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -1,9 +1,6 @@ package net.mullvad.mullvadvpn.util -import android.view.animation.Animation -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -12,31 +9,10 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.flow.take import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.lib.common.util.safeOffer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.talpid.util.EventNotifier -fun Animation.transitionFinished(): Flow = - 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 @@ -134,6 +110,13 @@ inline fun combine( suspend inline fun Deferred.awaitWithTimeoutOrNull(timeout: Long) = withTimeoutOrNull(timeout) { await() } +fun Deferred.getOrDefault(default: T) = + try { + getCompleted() + } catch (e: IllegalStateException) { + default + } + @Suppress("UNCHECKED_CAST") suspend inline fun Flow.retryWithExponentialBackOff( maxAttempts: Int, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt index 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 439d0c3c3b1d..eda867480246 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -3,15 +3,16 @@ package net.mullvad.mullvadvpn.viewmodel import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.DeviceState @@ -20,7 +21,6 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState import org.joda.time.DateTime @@ -30,32 +30,25 @@ class AccountViewModel( private val paymentUseCase: PaymentUseCase, deviceRepository: DeviceRepository ) : ViewModel() { - - private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - private val _enterTransitionEndAction = MutableSharedFlow() - - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() 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.not(), billingPaymentState = paymentAvailability?.toPaymentState() ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() - init { updateAccountExpiry() verifyPurchases() @@ -64,7 +57,7 @@ class AccountViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -74,10 +67,11 @@ class AccountViewModel( fun onLogoutClick() { accountRepository.logout() + viewModelScope.launch { _uiSideEffect.send(UiSideEffect.NavigateToLogin) } } - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + fun onCopyAccountNumber(accountNumber: String) { + viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) } } fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { @@ -116,7 +110,11 @@ class AccountViewModel( } sealed class UiSideEffect { + data object NavigateToLogin : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() + + data class CopyAccountNumber(val accountNumber: String) : UiSideEffect() } } @@ -124,8 +122,8 @@ data class AccountUiState( val deviceName: String?, val accountNumber: String?, val accountExpiry: DateTime?, + val showSitePayment: Boolean, val billingPaymentState: PaymentState? = null, - val paymentDialogData: PaymentDialogData? = null ) { companion object { fun default() = @@ -133,8 +131,8 @@ data class AccountUiState( deviceName = DeviceState.Unknown.deviceName(), accountNumber = DeviceState.Unknown.token(), accountExpiry = AccountExpiry.Missing.date(), + showSitePayment = false, billingPaymentState = PaymentState.Loading, - paymentDialogData = null, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt index f6549cded6a9..6b17592b8ef3 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..976eed1270e2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -3,20 +3,22 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -33,6 +35,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier @@ -41,7 +44,6 @@ import net.mullvad.mullvadvpn.util.daysFromNow import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorStateCause @OptIn(FlowPreview::class) class ConnectViewModel( @@ -51,10 +53,11 @@ class ConnectViewModel( private val inAppNotificationController: InAppNotificationController, private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, private val relayListUseCase: RelayListUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val paymentUseCase: PaymentUseCase ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val _shared: SharedFlow = serviceConnectionManager.connectionState @@ -90,9 +93,6 @@ class ConnectViewModel( accountExpiry, isTunnelInfoExpanded, deviceName -> - if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) { - _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView) - } ConnectUiState( location = when (tunnelRealState) { @@ -136,9 +136,12 @@ class ConnectViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) init { - // The create account cache is no longer needed as we have successfully reached the connect - // screen - accountRepository.clearCreatedAccountCache() + viewModelScope.launch { + // This once we get isOutOfTime true we will navigate to OutOfTime view. + outOfTimeUseCase.isOutOfTime().first { it == true } + _uiSideEffect.send(UiSideEffect.OutOfTime) + } + viewModelScope.launch { paymentUseCase.verifyPurchases { accountRepository.fetchAccountExpiry() } } @@ -155,12 +158,6 @@ class ConnectViewModel( private fun ConnectionProxy.tunnelRealStateFlow(): Flow = callbackFlowFromNotifier(this.onStateChange) - private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { - return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed) - ?.isCausedByExpiredAccount() - ?: false - } - fun toggleTunnelInfoExpansion() { _isTunnelInfoExpanded.value = _isTunnelInfoExpanded.value.not() } @@ -183,7 +180,7 @@ class ConnectViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -198,7 +195,7 @@ class ConnectViewModel( sealed interface UiSideEffect { data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect - data object OpenOutOfTimeView : UiSideEffect + data object OutOfTime : UiSideEffect } companion object { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt index 98648e0015bb..48a8782d0493 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt @@ -5,14 +5,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -33,22 +34,15 @@ class DeviceListViewModel( private val resources: Resources, private val dispatcher: CoroutineDispatcher = Dispatchers.Default ) : ViewModel() { - private val _stagedDeviceId = MutableStateFlow(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 = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() - @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 +59,47 @@ class DeviceListViewModel( ) } val isLoading = devices == null - val stagedDevice = devices?.firstOrNull { device -> device.id == stagedDeviceId } DeviceListUiState( deviceUiItems = deviceUiItems ?: emptyList(), isLoading = isLoading, - stagedDevice = stagedDevice ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL) - fun stageDeviceForRemoval(deviceId: DeviceId) { - _stagedDeviceId.value = deviceId - } - - fun clearStagedDevice() { - _stagedDeviceId.value = null - } - - fun 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.send( + DeviceListSideEffect.ShowToast( resources.getString(R.string.failed_to_remove_device) ) - refreshDeviceList() - } + ) + refreshDeviceList(accountToken) } } - } else { - _toastMessages.tryEmit(resources.getString(R.string.error_occurred)) - clearLoadingDevices() - clearStagedDevice() - refreshDeviceList() } } fun refreshDeviceState() = deviceRepository.refreshDeviceState() - fun refreshDeviceList() = - accountToken?.let { token -> deviceRepository.refreshDeviceList(token) } + fun refreshDeviceList(accountToken: String) = deviceRepository.refreshDeviceList(accountToken) private fun setLoadingDevice(deviceId: DeviceId) { _loadingDevices.value = _loadingDevices.value.toMutableList().apply { add(deviceId) } @@ -134,11 +109,11 @@ class DeviceListViewModel( _loadingDevices.value = _loadingDevices.value.toMutableList().apply { remove(deviceId) } } - private fun clearLoadingDevices() { - _loadingDevices.value = emptyList() - } - companion object { private const val DEVICE_REMOVAL_TIMEOUT_MILLIS = 5000L } } + +sealed interface DeviceListSideEffect { + data class ShowToast(val text: String) : DeviceListSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt new file mode 100644 index 000000000000..b931d4a7ba3f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -0,0 +1,171 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.net.InetAddress +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.constant.EMPTY_STRING +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.repository.SettingsRepository +import org.apache.commons.validator.routines.InetAddressValidator + +sealed interface DnsDialogSideEffect { + data object Complete : DnsDialogSideEffect +} + +data class DnsDialogViewModelState( + val customDnsList: List, + 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 = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private fun createViewState(ipAddress: String, vmState: DnsDialogViewModelState) = + DnsDialogViewState( + ipAddress, + ipAddress.validateDnsEntry(index, vmState.customDnsList), + ipAddress.isLocalAddress(), + isAllowLanEnabled = vmState.isAllowLanEnabled, + index == null + ) + + private fun String.validateDnsEntry( + index: Int?, + dnsList: List + ): DnsDialogViewState.ValidationResult = + when { + this.isBlank() || !this.isValidIp() -> { + DnsDialogViewState.ValidationResult.InvalidAddress + } + InetAddress.getByName(this).isDuplicateDnsEntry(index, dnsList) -> { + DnsDialogViewState.ValidationResult.DuplicateAddress + } + else -> DnsDialogViewState.ValidationResult.Success + } + + fun onDnsInputChange(ipAddress: String) { + _ipAddressInput.value = ipAddress + } + + fun onSaveDnsClick() = + viewModelScope.launch(dispatcher) { + if (!uiState.value.isValid()) return@launch + + val address = InetAddress.getByName(uiState.value.ipAddress) + + repository.updateCustomDnsList { + it.toMutableList().apply { + if (index != null) { + set(index, address) + } else { + add(address) + } + } + } + + _uiSideEffect.send(DnsDialogSideEffect.Complete) + } + + fun onRemoveDnsClick() = + viewModelScope.launch(dispatcher) { + repository.updateCustomDnsList { + it.filter { it.hostAddress != uiState.value.ipAddress } + } + _uiSideEffect.send(DnsDialogSideEffect.Complete) + } + + private fun String.isValidIp(): Boolean { + return inetAddressValidator.isValid(this) + } + + private fun String.isLocalAddress(): Boolean { + return isValidIp() && InetAddress.getByName(this).isLocalAddress() + } + + private fun InetAddress.isLocalAddress(): Boolean { + return isLinkLocalAddress || isSiteLocalAddress + } + + private fun InetAddress.isDuplicateDnsEntry( + currentIndex: Int? = null, + dnsList: List + ): Boolean = + dnsList.withIndex().any { (index, entry) -> + if (index == currentIndex) { + // Ignore current index, it may be the same + false + } else { + entry == this + } + } + + private fun Settings.addresses() = tunnelOptions.dnsOptions.customOptions.addresses + + companion object { + private const val EMPTY_STRING = "" + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt index 9178a221102a..3f95d7919399 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -2,13 +2,14 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.RelayFilterState @@ -23,8 +24,8 @@ import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase class FilterViewModel( private val relayListFilterUseCase: RelayListFilterUseCase, ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow() - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val selectedOwnership = MutableStateFlow(null) private val selectedProviders = MutableStateFlow>(emptyList()) @@ -101,7 +102,11 @@ class FilterViewModel( newSelectedOwnership, newSelectedProviders ) - _uiSideEffect.emit(Unit) + _uiSideEffect.send(FilterScreenSideEffect.CloseScreen) } } } + +sealed interface FilterScreenSideEffect { + data object CloseScreen : FilterScreenSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 34648f1d539b..2de5d42a05e3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -5,13 +5,17 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -23,6 +27,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Success import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.constant.LOGIN_TIMEOUT_MILLIS import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository @@ -30,6 +35,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.util.awaitWithTimeoutOrNull +import net.mullvad.mullvadvpn.util.getOrDefault private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L @@ -38,6 +44,8 @@ sealed interface LoginUiSideEffect { data object NavigateToConnect : LoginUiSideEffect + data object NavigateToOutOfTime : LoginUiSideEffect + data class TooManyDevices(val accountToken: AccountToken) : LoginUiSideEffect } @@ -51,8 +59,8 @@ class LoginViewModel( private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState) private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput) - private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val _uiState = combine( @@ -95,8 +103,19 @@ class LoginViewModel( when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) { LoginResult.Ok -> { launch { + val isOutOfTimeDeferred = async { + accountRepository.accountExpiryState + .filterIsInstance() + .map { it.expiryDateTime.isBeforeNow } + .first() + } delay(1000) - _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect) + val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) + if (isOutOfTime) { + _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime) + } else { + _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) + } } newDeviceNotificationUseCase.newDeviceCreated() Success @@ -114,10 +133,11 @@ class LoginViewModel( if (refreshResult.isAvailable()) { // Navigate to device list - _uiSideEffect.emit( + + _uiSideEffect.send( LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) ) - return@launch + Idle() } else { // Failed to fetch devices list Idle(LoginError.Unknown(result.toString())) @@ -137,7 +157,7 @@ class LoginViewModel( private suspend fun AccountCreationResult.mapToUiState(): LoginState? { return if (this is AccountCreationResult.Success) { - _uiSideEffect.emit(LoginUiSideEffect.NavigateToWelcome) + _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome) null } else { Idle(LoginError.UnableToCreateAccount) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt new file mode 100644 index 000000000000..db324e0b133e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -0,0 +1,39 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.util.isValidMtu + +class MtuDialogViewModel( + private val repository: SettingsRepository, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ViewModel() { + + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun onSaveClick(mtuValue: Int) = + viewModelScope.launch(dispatcher) { + if (mtuValue.isValidMtu()) { + repository.setWireguardMtu(mtuValue) + } + _uiSideEffect.send(MtuDialogSideEffect.Complete) + } + + fun onRestoreClick() = + viewModelScope.launch(dispatcher) { + repository.setWireguardMtu(null) + _uiSideEffect.send(MtuDialogSideEffect.Complete) + } +} + +sealed interface MtuDialogSideEffect { + data object Complete : MtuDialogSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt new file mode 100644 index 000000000000..eff31be0eecb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt @@ -0,0 +1,119 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import com.ramcosta.composedestinations.spec.DestinationSpec +import com.ramcosta.composedestinations.utils.destination +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.destinations.SplashDestination +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState + +private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) + +class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : + ViewModel(), LifecycleEventObserver, NavController.OnDestinationChangedListener { + + private val lifecycleFlow: MutableSharedFlow = MutableSharedFlow() + private val destinationFlow: MutableSharedFlow> = MutableSharedFlow() + + @OptIn(FlowPreview::class) + val uiSideEffect = + combine(lifecycleFlow, serviceConnectionManager.connectionState, destinationFlow) { + event, + connEvent, + destination -> + toDaemonState(event, connEvent, destination) + } + .map { state -> + when (state) { + is DaemonState.Show -> DaemonScreenEvent.Show + is DaemonState.Hidden.Ignored -> DaemonScreenEvent.Remove + DaemonState.Hidden.Connected -> DaemonScreenEvent.Remove + } + } + .distinctUntilChanged() + // We debounce any disconnected state to let the UI have some time to connect after a + // onStart/onStop event. + .debounce { + when (it) { + is DaemonScreenEvent.Remove -> 0.seconds + is DaemonScreenEvent.Show -> SERVICE_DISCONNECT_DEBOUNCE + } + } + .distinctUntilChanged() + .shareIn(viewModelScope, SharingStarted.Eagerly) + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + viewModelScope.launch { lifecycleFlow.emit(event) } + } + + private fun toDaemonState( + lifecycleEvent: Lifecycle.Event, + serviceState: ServiceConnectionState, + currentDestination: DestinationSpec<*> + ): DaemonState { + // In these destinations we don't care about showing the NoDaemonScreen + if (currentDestination in noServiceDestinations) { + return DaemonState.Hidden.Ignored + } + + return if (lifecycleEvent.targetState.isAtLeast(Lifecycle.State.STARTED)) { + // If we are started we want to show the overlay if we are not connected to daemon + when (serviceState) { + is ServiceConnectionState.ConnectedNotReady, + ServiceConnectionState.Disconnected -> DaemonState.Show + is ServiceConnectionState.ConnectedReady -> DaemonState.Hidden.Connected + } + } else { + // If we are stopped we intentionally stop service and don't care about showing overlay. + DaemonState.Hidden.Ignored + } + } + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + viewModelScope.launch { + controller.currentBackStackEntry?.destination()?.let { destinationFlow.emit(it) } + } + } + + companion object { + private val SERVICE_DISCONNECT_DEBOUNCE = 2.seconds + } +} + +sealed interface DaemonState { + data object Show : DaemonState + + sealed interface Hidden : DaemonState { + data object Ignored : Hidden + + data object Connected : Hidden + } +} + +sealed interface DaemonScreenEvent { + data object Show : DaemonScreenEvent + + data object Remove : DaemonScreenEvent +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 001469c26b4d..8c9a39bbdc7c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -1,23 +1,23 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -26,22 +26,22 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState -import org.joda.time.DateTime class OutOfTimeViewModel( private val accountRepository: AccountRepository, private val serviceConnectionManager: ServiceConnectionManager, private val deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val pollAccountExpiry: Boolean = true, ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState = serviceConnectionManager.connectionState @@ -57,13 +57,12 @@ class OutOfTimeViewModel( serviceConnection.connectionProxy.tunnelStateFlow(), deviceRepository.deviceState, paymentUseCase.paymentAvailability, - paymentUseCase.purchaseResult - ) { tunnelState, deviceState, paymentAvailability, purchaseResult -> + ) { tunnelState, deviceState, paymentAvailability -> OutOfTimeUiState( tunnelState = tunnelState, deviceName = deviceState.deviceName() ?: "", + showSitePayment = IS_PLAY_BUILD.not(), billingPaymentState = paymentAvailability?.toPaymentState(), - paymentDialogData = purchaseResult?.toPaymentDialogData() ) } } @@ -71,18 +70,11 @@ class OutOfTimeViewModel( init { viewModelScope.launch { - accountRepository.accountExpiryState.collectLatest { accountExpiry -> - accountExpiry.date()?.let { expiry -> - val tomorrow = DateTime.now().plusHours(20) - - if (expiry.isAfter(tomorrow)) { - // Reset purchase state - paymentUseCase.resetPurchaseResult() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) - } - } - } + outOfTimeUseCase.isOutOfTime().first { it == false } + paymentUseCase.resetPurchaseResult() + _uiSideEffect.send(UiSideEffect.OpenConnectScreen) } + viewModelScope.launch { while (pollAccountExpiry) { updateAccountExpiry() @@ -98,7 +90,7 @@ class OutOfTimeViewModel( fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountView( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -110,10 +102,6 @@ class OutOfTimeViewModel( viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } - private fun verifyPurchases() { viewModelScope.launch { paymentUseCase.verifyPurchases() @@ -132,7 +120,7 @@ class OutOfTimeViewModel( // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) + // _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) } else { fetchPaymentAvailability() verifyPurchases() // Attempt to verify again diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt new file mode 100644 index 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..f8e6b13f3d02 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt @@ -1,10 +1,27 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class PrivacyDisclaimerViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository ) : ViewModel() { - fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + + private val _uiSideEffect = + Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun setPrivacyDisclosureAccepted() { + privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + viewModelScope.launch { _uiSideEffect.send(PrivacyDisclaimerUiSideEffect.NavigateToLogin) } + } +} + +sealed interface PrivacyDisclaimerUiSideEffect { + data object NavigateToLogin : PrivacyDisclaimerUiSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt index 82e66b0c4b98..52311f82a099 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -3,10 +3,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.constant.MINIMUM_LOADING_TIME_MILLIS @@ -16,7 +19,6 @@ import net.mullvad.mullvadvpn.dataproxy.UserReport import net.mullvad.mullvadvpn.repository.ProblemReportRepository data class ReportProblemUiState( - val showConfirmNoEmail: Boolean = false, val sendingState: SendingReportUiState? = null, val email: String = "", val description: String = "", @@ -30,22 +32,23 @@ sealed interface SendingReportUiState { data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState } +sealed interface ReportProblemSideEffect { + data object ShowConfirmNoEmail : ReportProblemSideEffect +} + class ReportProblemViewModel( private val mullvadProblemReporter: MullvadProblemReport, private val problemReportRepository: ProblemReportRepository ) : ViewModel() { - private val showConfirmNoEmail = MutableStateFlow(false) private val sendingState: MutableStateFlow = MutableStateFlow(null) val uiState = combine( - showConfirmNoEmail, sendingState, problemReportRepository.problemReport, - ) { showConfirmNoEmail, pendingState, userReport -> + ) { pendingState, userReport -> ReportProblemUiState( - showConfirmNoEmail = showConfirmNoEmail, sendingState = pendingState, email = userReport.email ?: "", description = userReport.description, @@ -53,18 +56,17 @@ class ReportProblemViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReportProblemUiState()) - fun sendReport( - email: String, - description: String, - ) { + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun sendReport(email: String, description: String, skipEmptyEmailCheck: Boolean = false) { viewModelScope.launch { val userEmail = email.trim() val nullableEmail = if (email.isEmpty()) null else userEmail - if (shouldShowConfirmNoEmail(nullableEmail)) { - showConfirmNoEmail.tryEmit(true) + if (!skipEmptyEmailCheck && shouldShowConfirmNoEmail(nullableEmail)) { + _uiSideEffect.send(ReportProblemSideEffect.ShowConfirmNoEmail) } else { - sendingState.tryEmit(SendingReportUiState.Sending) - showConfirmNoEmail.tryEmit(false) + sendingState.emit(SendingReportUiState.Sending) // Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS val deferredResult = async { @@ -87,10 +89,6 @@ class ReportProblemViewModel( sendingState.tryEmit(null) } - fun dismissConfirmNoEmail() { - showConfirmNoEmail.tryEmit(false) - } - fun updateEmail(email: String) { problemReportRepository.setEmail(email) } @@ -100,9 +98,7 @@ class ReportProblemViewModel( } private fun shouldShowConfirmNoEmail(userEmail: String?): Boolean = - userEmail.isNullOrEmpty() && - !uiState.value.showConfirmNoEmail && - uiState.value.sendingState !is SendingReportUiState + userEmail.isNullOrEmpty() && uiState.value.sendingState !is SendingReportUiState private fun SendProblemReportResult.toUiResult(email: String?): SendingReportUiState = when (this) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index caddae313bdc..dc9d5e7d6fc2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -2,12 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState @@ -28,9 +29,6 @@ class SelectLocationViewModel( private val relayListUseCase: RelayListUseCase, private val relayListFilterUseCase: RelayListFilterUseCase ) : ViewModel() { - - private val _closeAction = MutableSharedFlow() - private val _enterTransitionEndAction = MutableSharedFlow() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = @@ -83,20 +81,13 @@ class SelectLocationViewModel( SelectLocationUiState.Loading ) - @Suppress("konsist.ensure public properties use permitted names") - val uiCloseAction = _closeAction.asSharedFlow() - - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() fun selectRelay(relayItem: RelayItem) { relayListUseCase.updateSelectedRelayLocation(relayItem.location) serviceConnectionManager.connectionProxy()?.connect() - viewModelScope.launch { _closeAction.emit(Unit) } - } - - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + viewModelScope.launch { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) } } fun onSearchTermInput(searchTerm: String) { @@ -147,3 +138,7 @@ class SelectLocationViewModel( private const val EMPTY_SEARCH_TERM = "" } } + +sealed interface SelectLocationSideEffect { + data object CloseScreen : SelectLocationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt index 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..8163fb977069 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -0,0 +1,110 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.ipc.events +import net.mullvad.mullvadvpn.model.AccountAndDevice +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository + +class SplashViewModel( + private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, + private val deviceRepository: DeviceRepository, + private val messageHandler: MessageHandler, +) : ViewModel() { + + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun start() { + viewModelScope.launch { + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + _uiSideEffect.send(getStartDestination()) + } else { + _uiSideEffect.send(SplashUiSideEffect.NavigateToPrivacyDisclaimer) + } + } + } + + private suspend fun getStartDestination(): SplashUiSideEffect { + val deviceState = + deviceRepository.deviceState + .map { + when (it) { + DeviceState.Initial -> null + is DeviceState.LoggedIn -> + ValidStartDeviceState.LoggedIn(it.accountAndDevice) + DeviceState.LoggedOut -> ValidStartDeviceState.LoggedOut + DeviceState.Revoked -> ValidStartDeviceState.Revoked + DeviceState.Unknown -> null + } + } + .filterNotNull() + .first() + + return when (deviceState) { + ValidStartDeviceState.LoggedOut -> SplashUiSideEffect.NavigateToLogin + ValidStartDeviceState.Revoked -> SplashUiSideEffect.NavigateToRevoked + is ValidStartDeviceState.LoggedIn -> getLoggedInStartDestination() + } + } + + // We know the user is logged in, but we need to find out if their account has expired + private suspend fun getLoggedInStartDestination(): SplashUiSideEffect { + val expiry = + viewModelScope.async { + messageHandler.events().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 3691fc79b5a4..0cc55b992c7d 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..80b51a811c31 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt @@ -7,12 +7,15 @@ import androidx.lifecycle.viewModelScope import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,29 +35,32 @@ import net.mullvad.mullvadvpn.model.WireguardConstraints import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase -import net.mullvad.mullvadvpn.util.isValidMtu -import org.apache.commons.validator.routines.InetAddressValidator +import net.mullvad.mullvadvpn.util.isCustom + +sealed interface VpnSettingsSideEffect { + data class ShowToast(val message: String) : VpnSettingsSideEffect + + data object NavigateToDnsDialog : VpnSettingsSideEffect +} class VpnSettingsViewModel( private val repository: SettingsRepository, - private val inetAddressValidator: InetAddressValidator, private val resources: Resources, portRangeUseCase: PortRangeUseCase, private val relayListUseCase: RelayListUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _toastMessages = MutableSharedFlow(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() - 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 +69,11 @@ class VpnSettingsViewModel( customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(), contentBlockersOptions = settings?.contentBlockersSettings() ?: DefaultDnsOptions(), - isAllowLanEnabled = settings?.allowLan ?: false, selectedObfuscation = settings?.selectedObfuscationSettings() ?: SelectedObfuscation.Off, - dialogState = dialogState, quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off, selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any(), + customWireguardPort = customWgPort, availablePortRanges = portRanges ) } @@ -87,142 +92,20 @@ class VpnSettingsViewModel( VpnSettingsUiState.createDefault() ) - fun onMtuCellClick() { - dialogState.update { VpnSettingsDialogState.MtuDialog(vmState.value.mtuValue) } - } - - fun onSaveMtuClick(mtuValue: Int) = + init { viewModelScope.launch(dispatcher) { - if (mtuValue.isValidMtu()) { - repository.setWireguardMtu(mtuValue) - } - hideDialog() - } - - fun onRestoreMtuClick() = - viewModelScope.launch(dispatcher) { - repository.setWireguardMtu(null) - hideDialog() - } - - fun onCancelDialogClick() { - hideDialog() - } - - fun onLocalNetworkSharingInfoClick() { - dialogState.update { VpnSettingsDialogState.LocalNetworkSharingInfoDialog } - } - - fun onContentsBlockerInfoClick() { - dialogState.update { VpnSettingsDialogState.ContentBlockersInfoDialog } - } - - fun onCustomDnsInfoClick() { - dialogState.update { VpnSettingsDialogState.CustomDnsInfoDialog } - } - - fun onMalwareInfoClick() { - dialogState.update { VpnSettingsDialogState.MalwareInfoDialog } - } - - fun onDismissInfoClick() { - hideDialog() - } - - fun onDnsClick(index: Int? = null) { - val stagedDns = - if (index == null) { - StagedDns.NewDns( - item = CustomDnsItem.default(), - validationResult = StagedDns.ValidationResult.InvalidAddress - ) - } else { - vmState.value.customDnsList.getOrNull(index)?.let { listItem -> - StagedDns.EditDns(item = listItem, index = index) + val initialSettings = repository.settingsUpdates.filterNotNull().first() + customPort.update { + val initialPort = initialSettings.getWireguardPort() + if (initialPort.isCustom()) { + initialPort + } else { + null } } - - if (stagedDns != null) { - dialogState.update { VpnSettingsDialogState.DnsDialog(stagedDns) } } } - fun onDnsInputChange(ipAddress: String) { - dialogState.update { state -> - val dialog = state as? VpnSettingsDialogState.DnsDialog ?: return - - val error = - when { - ipAddress.isBlank() || ipAddress.isValidIp().not() -> { - StagedDns.ValidationResult.InvalidAddress - } - ipAddress.isDuplicateDns((state.stagedDns as? StagedDns.EditDns)?.index) -> { - StagedDns.ValidationResult.DuplicateAddress - } - else -> StagedDns.ValidationResult.Success - } - - return@update VpnSettingsDialogState.DnsDialog( - stagedDns = - if (dialog.stagedDns is StagedDns.EditDns) { - StagedDns.EditDns( - item = - CustomDnsItem( - address = ipAddress, - isLocal = ipAddress.isLocalAddress() - ), - validationResult = error, - index = dialog.stagedDns.index - ) - } else { - StagedDns.NewDns( - item = - CustomDnsItem( - address = ipAddress, - isLocal = ipAddress.isLocalAddress() - ), - validationResult = error - ) - } - ) - } - } - - fun onSaveDnsClick() = - viewModelScope.launch(dispatcher) { - val dialog = - vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch - - if (dialog.stagedDns.isValid().not()) return@launch - - val updatedList = - vmState.value.customDnsList - .toMutableList() - .map { it.address } - .toMutableList() - .let { activeList -> - if (dialog.stagedDns is StagedDns.EditDns) { - activeList - .apply { - set(dialog.stagedDns.index, dialog.stagedDns.item.address) - } - .asInetAddressList() - } else { - activeList - .apply { add(dialog.stagedDns.item.address) } - .asInetAddressList() - } - } - - repository.setDnsOptions( - isCustomDnsEnabled = true, - dnsList = updatedList, - contentBlockersOptions = vmState.value.contentBlockersOptions - ) - - hideDialog() - } - fun onToggleAutoConnect(isEnabled: Boolean) { viewModelScope.launch(dispatcher) { repository.setAutoConnect(isEnabled) } } @@ -231,12 +114,19 @@ class VpnSettingsViewModel( viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) } } - fun onToggleDnsClick(isEnabled: Boolean) { - updateCustomDnsState(isEnabled) - if (isEnabled && vmState.value.customDnsList.isEmpty()) { - onDnsClick(null) + fun onDnsDialogDismissed() { + if (vmState.value.customDnsList.isEmpty()) { + onToggleCustomDns(false) + } + } + + fun onToggleCustomDns(enable: Boolean) { + repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default) + if (enable && vmState.value.customDnsList.isEmpty()) { + viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog) } + } else { + showApplySettingChangesWarningToast() } - showApplySettingChangesWarningToast() } fun onToggleBlockAds(isEnabled: Boolean) { @@ -281,29 +171,9 @@ class VpnSettingsViewModel( showApplySettingChangesWarningToast() } - fun onRemoveDnsClick() = - viewModelScope.launch(dispatcher) { - val dialog = - vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch - - val updatedList = - vmState.value.customDnsList - .toMutableList() - .filter { it.address != dialog.stagedDns.item.address } - .map { it.address } - .asInetAddressList() - - repository.setDnsOptions( - isCustomDnsEnabled = vmState.value.isCustomDnsEnabled && updatedList.isNotEmpty(), - dnsList = updatedList, - contentBlockersOptions = vmState.value.contentBlockersOptions - ) - hideDialog() - } - fun onStopEvent() { if (vmState.value.customDnsList.isEmpty()) { - updateCustomDnsState(false) + repository.setDnsState(DnsState.Default) } } @@ -318,31 +188,27 @@ class VpnSettingsViewModel( } } - fun onObfuscationInfoClick() { - dialogState.update { VpnSettingsDialogState.ObfuscationInfoDialog } - } - fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) { viewModelScope.launch(dispatcher) { repository.setWireguardQuantumResistant(quantumResistant) } } - fun onQuantumResistanceInfoClicked() { - dialogState.update { VpnSettingsDialogState.QuantumResistanceInfoDialog } - } - fun onWireguardPortSelected(port: Constraint) { + if (port.isCustom()) { + customPort.update { port } + } relayListUseCase.updateSelectedWireguardConstraints(WireguardConstraints(port = port)) - hideDialog() - } - - fun onWireguardPortInfoClicked() { - dialogState.update { VpnSettingsDialogState.WireguardPortInfoDialog } } - fun onShowCustomPortDialog() { - dialogState.update { VpnSettingsDialogState.CustomPortDialog } + fun resetCustomPort() { + customPort.update { null } + // If custom port was selected, update selection to be any. + if (vmState.value.selectedWireguardPort.isCustom()) { + relayListUseCase.updateSelectedWireguardConstraints( + WireguardConstraints(port = Constraint.Any()) + ) + } } private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = @@ -354,26 +220,6 @@ class VpnSettingsViewModel( ) } - private fun hideDialog() { - dialogState.update { null } - } - - fun onCancelDns() { - if ( - vmState.value.dialogState is VpnSettingsDialogState.DnsDialog && - vmState.value.customDnsList.isEmpty() - ) { - onToggleDnsClick(false) - } - hideDialog() - } - - private fun String.isDuplicateDns(stagedIndex: Int? = null): Boolean { - return vmState.value.customDnsList - .filterIndexed { index, listItem -> index != stagedIndex && listItem.address == this } - .isNotEmpty() - } - private fun List.asInetAddressList(): List { return try { map { InetAddress.getByName(it) } @@ -408,32 +254,20 @@ class VpnSettingsViewModel( (relaySettings as RelaySettings.Normal).relayConstraints.wireguardConstraints.port } - private fun String.isValidIp(): Boolean { - return inetAddressValidator.isValid(this) - } - - private fun String.isLocalAddress(): Boolean { - return isValidIp() && InetAddress.getByName(this).isLocalAddress() - } - private fun InetAddress.isLocalAddress(): Boolean { return isLinkLocalAddress || isSiteLocalAddress } - private fun updateCustomDnsState(isEnabled: Boolean) { - viewModelScope.launch(dispatcher) { - repository.setDnsOptions( - isEnabled, - dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(), - contentBlockersOptions = vmState.value.contentBlockersOptions + private fun showApplySettingChangesWarningToast() { + viewModelScope.launch { + _uiSideEffect.send( + VpnSettingsSideEffect.ShowToast( + resources.getString(R.string.settings_changes_effect_warning_short) + ) ) } } - private fun showApplySettingChangesWarningToast() { - _toastMessages.tryEmit(resources.getString(R.string.settings_changes_effect_warning_short)) - } - companion object { private const val EMPTY_STRING = "" } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt index 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 69e9764d4f21..7c77d183caa1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -1,25 +1,25 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -27,13 +27,12 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS import net.mullvad.mullvadvpn.util.addDebounceForUnknownState import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState -import org.joda.time.DateTime @OptIn(FlowPreview::class) class WelcomeViewModel( @@ -41,10 +40,11 @@ class WelcomeViewModel( private val deviceRepository: DeviceRepository, private val serviceConnectionManager: ServiceConnectionManager, private val paymentUseCase: PaymentUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val pollAccountExpiry: Boolean = true ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState = serviceConnectionManager.connectionState @@ -62,39 +62,30 @@ 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() ) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) init { - viewModelScope.launch { - accountRepository.accountExpiryState.collectLatest { accountExpiry -> - accountExpiry.date()?.let { expiry -> - val tomorrow = DateTime.now().plusHours(20) - - if (expiry.isAfter(tomorrow)) { - // Reset purchase state - paymentUseCase.resetPurchaseResult() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) - } - } - } - } viewModelScope.launch { while (pollAccountExpiry) { updateAccountExpiry() delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } + viewModelScope.launch { + outOfTimeUseCase.isOutOfTime().first { it == false } + paymentUseCase.resetPurchaseResult() + _uiSideEffect.send(UiSideEffect.OpenConnectScreen) + } verifyPurchases() fetchPaymentAvailability() } @@ -104,7 +95,7 @@ class WelcomeViewModel( fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountView( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -112,10 +103,6 @@ class WelcomeViewModel( } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } - private fun verifyPurchases() { viewModelScope.launch { paymentUseCase.verifyPurchases() @@ -123,7 +110,6 @@ class WelcomeViewModel( } } - @OptIn(FlowPreview::class) private fun fetchPaymentAvailability() { viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } } @@ -135,7 +121,7 @@ class WelcomeViewModel( // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) + // Emission of out of time navigation is handled by launch in onStart } else { fetchPaymentAvailability() verifyPurchases() // Attempt to verify again diff --git a/android/app/src/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..74683813aede --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt @@ -0,0 +1,128 @@ +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(null, 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(null, awaitItem()) + events.emit(errorChange) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `Tunnel is connected should emit false`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + val tunnelStateChanges = + listOf( + TunnelState.Disconnected, + TunnelState.Connected(mockk(), null), + TunnelState.Connecting(null, null), + TunnelState.Disconnecting(mockk()), + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), + ) + .map(Event::TunnelStateChange) + + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + assertEquals(null, awaitItem()) + events.emit(tunnelStateChanges.first()) + expiry.emit(expiredAccountExpiry) + assertEquals(false, awaitItem()) + + tunnelStateChanges.forEach { events.emit(it) } + + // Should not emit again + expectNoEvents() + } + } + + @Test + fun `Account expiry that has expired should emit true`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().minusDays(1)) + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + assertEquals(null, awaitItem()) + expiry.emit(expiredAccountExpiry) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `Account expiry that has not expired should emit false`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + assertEquals(null, awaitItem()) + expiry.emit(expiredAccountExpiry) + assertEquals(false, awaitItem()) + } + } + + @Test + fun `Account that expires without new expiry event`() = runTest { + // Arrange + val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(62)) + + // Act, Assert + outOfTimeUseCase.isOutOfTime().test { + // Initial event + assertEquals(null, awaitItem()) + + expiry.emit(expiredAccountExpiry) + assertEquals(false, awaitItem()) + 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..3350178ca323 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt @@ -8,15 +8,17 @@ import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify -import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.repository.ChangelogRepository import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class ChangelogViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() @MockK private lateinit var mockedChangelogRepository: ChangelogRepository @@ -28,7 +30,6 @@ class ChangelogViewModelTest { mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just Runs - viewModel = ChangelogViewModel(mockedChangelogRepository, 1, false) } @After @@ -37,54 +38,41 @@ class ChangelogViewModelTest { } @Test - fun testInitialState() = runTest { - // Arrange, Act, Assert - viewModel.uiState.test { assertEquals(ChangelogDialogUiState.Hide, awaitItem()) } + fun testUpToDateVersionCodeShouldNotEmitChangelog() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns + buildVersionCode + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + + // If we have the most up to date version code, we should not show the changelog dialog + viewModel.uiSideEffect.test { expectNoEvents() } } @Test - fun testShowAndDismissChangelogDialog() = runTest { - viewModel.uiState.test { - // Arrange - val fakeList = listOf("test") - every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - -1 - every { mockedChangelogRepository.getLastVersionChanges() } returns fakeList - - // Assert initial ui state - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) + fun testNotUpToDateVersionCodeShouldEmitChangelog() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 + every { mockedChangelogRepository.getLastVersionChanges() } returns listOf("bla", "bla") - // Refresh and verify that the dialog should be shown - viewModel.refreshChangelogDialogUiState() - assertEquals(ChangelogDialogUiState.Show(fakeList), awaitItem()) - - // Dismiss dialog and verify that the dialog should be hidden - viewModel.dismissChangelogDialog() - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) - verify { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(1) } - } + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + // Given a new version with a change log we should return it + viewModel.uiSideEffect.test { assertNotNull(awaitItem()) } } @Test - fun testShowCaseChangelogWithEmptyListDialog() = runTest { - viewModel.uiState.test { - // Arrange - val fakeEmptyList = emptyList() - every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - -1 - every { mockedChangelogRepository.getLastVersionChanges() } returns fakeEmptyList - - // Assert initial ui state - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) + fun testEmptyChangelogShouldNotEmitChangelog() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 + every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList() - // Refresh and verify that the Ui state remain same due list being empty - viewModel.refreshChangelogDialogUiState() - expectNoEvents() - } + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + // Given a new version with a change log we should not return it + viewModel.uiSideEffect.test { expectNoEvents() } } companion object { private const val EVENT_NOTIFIER_EXTENSION_CLASS = "net.mullvad.talpid.util.EventNotifierExtensionsKt" + private const val buildVersionCode = 10 } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 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 7eb35404d077..c402a3103e6f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Success import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountHistory import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.DeviceListEvent @@ -28,6 +29,7 @@ import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import org.joda.time.DateTime import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -113,6 +115,8 @@ class LoginViewModelTest { val uiStates = loginViewModel.uiState.testIn(backgroundScope) val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope) coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok + coEvery { mockedAccountRepository.accountExpiryState } returns + MutableStateFlow(AccountExpiry.Available(DateTime.now().plusDays(3))) // Act, Assert uiStates.skipDefaultItem() diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt index dad51eab59ae..0232f12e89b3 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.viewModelScope import app.cash.turbine.test import io.mockk.coEvery @@ -12,18 +11,15 @@ import io.mockk.unmockkAll import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.DeviceState @@ -37,8 +33,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.joda.time.ReadableInstant @@ -50,12 +46,13 @@ import org.junit.Test class OutOfTimeViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - private val serviceConnectionState = + private val serviceConnectionStateFlow = MutableStateFlow(ServiceConnectionState.Disconnected) - private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) - private val deviceState = MutableStateFlow(DeviceState.Initial) - private val paymentAvailability = MutableStateFlow(null) - private val purchaseResult = MutableStateFlow(null) + private val accountExpiryStateFlow = MutableStateFlow(AccountExpiry.Missing) + private val deviceStateFlow = MutableStateFlow(DeviceState.Initial) + private val paymentAvailabilityFlow = MutableStateFlow(null) + private val purchaseResultFlow = MutableStateFlow(null) + private val outOfTimeFlow = MutableStateFlow(true) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -68,6 +65,7 @@ class OutOfTimeViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) + private val mockOutOfTimeUseCase: OutOfTimeUseCase = mockk(relaxed = true) private lateinit var viewModel: OutOfTimeViewModel @@ -76,19 +74,21 @@ class OutOfTimeViewModelTest { mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState + every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState - every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow - every { mockDeviceRepository.deviceState } returns deviceState + every { mockDeviceRepository.deviceState } returns deviceStateFlow - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult + coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow - coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability + coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow + + coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow viewModel = OutOfTimeViewModel( @@ -96,6 +96,7 @@ class OutOfTimeViewModelTest { serviceConnectionManager = mockServiceConnectionManager, deviceRepository = mockDeviceRepository, paymentUseCase = mockPaymentUseCase, + outOfTimeUseCase = mockOutOfTimeUseCase, pollAccountExpiry = false ) } @@ -134,7 +135,7 @@ class OutOfTimeViewModelTest { viewModel.uiState.test { assertEquals(OutOfTimeUiState(deviceName = ""), awaitItem()) eventNotifierTunnelRealState.notify(tunnelRealStateTestItem) - serviceConnectionState.value = + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem() assertEquals(tunnelRealStateTestItem, result.tunnelState) @@ -150,7 +151,7 @@ class OutOfTimeViewModelTest { // Act, Assert viewModel.uiSideEffect.test { - accountExpiryState.value = AccountExpiry.Available(mockExpiryDate) + outOfTimeFlow.value = false val action = awaitItem() assertIs(action) } @@ -174,8 +175,8 @@ class OutOfTimeViewModelTest { fun testBillingProductsUnavailableState() = runTest { // Arrange val productsUnavailable = PaymentAvailability.ProductsUnavailable - paymentAvailability.value = productsUnavailable - serviceConnectionState.value = + paymentAvailabilityFlow.value = productsUnavailable + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -189,8 +190,8 @@ class OutOfTimeViewModelTest { fun testBillingProductsGenericErrorState() = runTest { // Arrange val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk()) - paymentAvailability.value = paymentAvailabilityError - serviceConnectionState.value = + paymentAvailabilityFlow.value = paymentAvailabilityError + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -204,8 +205,8 @@ class OutOfTimeViewModelTest { fun testBillingProductsBillingErrorState() = runTest { // Arrange val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable - paymentAvailability.value = paymentAvailabilityError - serviceConnectionState.value = + paymentAvailabilityFlow.value = paymentAvailabilityError + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -221,8 +222,8 @@ class OutOfTimeViewModelTest { val mockProduct: PaymentProduct = mockk() val expectedProductList = listOf(mockProduct) val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailability.value = productsAvailable - serviceConnectionState.value = + paymentAvailabilityFlow.value = productsAvailable + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -233,44 +234,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/ReportProblemViewModelTest.kt similarity index 66% rename from android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt rename to android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt index e0b1c5274c5e..5726c6249c96 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt @@ -20,10 +20,11 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -class ReportProblemModelTest { +class ReportProblemViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() @MockK private lateinit var mockMullvadProblemReport: MullvadProblemReport + @MockK(relaxed = true) private lateinit var mockProblemReportRepository: ProblemReportRepository private val problemReportFlow = MutableStateFlow(UserReport("", "")) @@ -87,16 +88,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 +124,44 @@ class ReportProblemModelTest { coEvery { mockMullvadProblemReport.sendReport(any()) } returns SendProblemReportResult.Success val email = "my@email.com" + val description = "My description" + + // This might look a bit weird, and is not optimal. An alternative would be to use the real + // ProblemReportRepository, but that would complicate the other tests. This is a compromise. + coEvery { mockProblemReportRepository.setEmail(any()) } answers + { + problemReportFlow.value = problemReportFlow.value.copy(email = arg(0)) + } + coEvery { mockProblemReportRepository.setDescription(any()) } answers + { + problemReportFlow.value = problemReportFlow.value.copy(description = arg(0)) + } // Act, Assert viewModel.uiState.test { - assertEquals(awaitItem(), ReportProblemUiState(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 +189,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 74d7d80c1901..5ad1af11823f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt @@ -120,10 +120,10 @@ class SelectLocationViewModelTest { every { mockRelayListUseCase.updateSelectedRelayLocation(mockLocation) } returns Unit // Act, Assert - viewModel.uiCloseAction.test { + viewModel.uiSideEffect.test { viewModel.selectRelay(mockRelayItem) // Await an empty item - assertEquals(Unit, awaitItem()) + assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) verify { connectionProxyMock.connect() mockRelayListUseCase.updateSelectedRelayLocation(mockLocation) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt index 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..433a9f570903 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt @@ -1,28 +1,23 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.viewModelScope import app.cash.turbine.test import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.model.AccountAndDevice import net.mullvad.mullvadvpn.model.AccountExpiry @@ -37,8 +32,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.joda.time.ReadableInstant @@ -50,12 +45,13 @@ import org.junit.Test class WelcomeViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - private val serviceConnectionState = + private val serviceConnectionStateFlow = MutableStateFlow(ServiceConnectionState.Disconnected) - private val deviceState = MutableStateFlow(DeviceState.Initial) - private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) - private val purchaseResult = MutableStateFlow(null) - private val paymentAvailability = MutableStateFlow(null) + private val deviceStateFlow = MutableStateFlow(DeviceState.Initial) + private val accountExpiryStateFlow = MutableStateFlow(AccountExpiry.Missing) + private val purchaseResultFlow = MutableStateFlow(null) + private val paymentAvailabilityFlow = MutableStateFlow(null) + private val outOfTimeFlow = MutableStateFlow(true) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -68,6 +64,7 @@ class WelcomeViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) + private val mockOutOfTimeUseCase: OutOfTimeUseCase = mockk(relaxed = true) private lateinit var viewModel: WelcomeViewModel @@ -76,19 +73,21 @@ class WelcomeViewModelTest { mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockDeviceRepository.deviceState } returns deviceState + every { mockDeviceRepository.deviceState } returns deviceStateFlow - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState + every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState - every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult + coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow - coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability + coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailabilityFlow + + coEvery { mockOutOfTimeUseCase.isOutOfTime() } returns outOfTimeFlow viewModel = WelcomeViewModel( @@ -96,6 +95,7 @@ class WelcomeViewModelTest { deviceRepository = mockDeviceRepository, serviceConnectionManager = mockServiceConnectionManager, paymentUseCase = mockPaymentUseCase, + outOfTimeUseCase = mockOutOfTimeUseCase, pollAccountExpiry = false ) } @@ -134,7 +134,7 @@ class WelcomeViewModelTest { viewModel.uiState.test { assertEquals(WelcomeUiState(), awaitItem()) eventNotifierTunnelUiState.notify(tunnelUiStateTestItem) - serviceConnectionState.value = + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem() assertEquals(tunnelUiStateTestItem, result.tunnelState) @@ -142,27 +142,26 @@ class WelcomeViewModelTest { } @Test - fun testUpdateAccountNumber() = - runTest(testCoroutineRule.testDispatcher) { - // Arrange - val expectedAccountNumber = "4444555566667777" - val device: Device = mockk() - every { device.displayName() } returns "" + fun testUpdateAccountNumber() = runTest { + // Arrange + val expectedAccountNumber = "4444555566667777" + val device: Device = mockk() + every { device.displayName() } returns "" - // Act, Assert - viewModel.uiState.test { - assertEquals(WelcomeUiState(), awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - deviceState.value = - DeviceState.LoggedIn( - accountAndDevice = - AccountAndDevice(account_token = expectedAccountNumber, device = device) - ) - val result = awaitItem() - assertEquals(expectedAccountNumber, result.accountNumber) - } + // Act, Assert + viewModel.uiState.test { + assertEquals(WelcomeUiState(), awaitItem()) + paymentAvailabilityFlow.value = null + deviceStateFlow.value = + DeviceState.LoggedIn( + accountAndDevice = + AccountAndDevice(account_token = expectedAccountNumber, device = device) + ) + serviceConnectionStateFlow.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + assertEquals(expectedAccountNumber, awaitItem().accountNumber) } + } @Test fun testOpenConnectScreen() = @@ -173,7 +172,7 @@ class WelcomeViewModelTest { // Act, Assert viewModel.uiSideEffect.test { - accountExpiryState.value = AccountExpiry.Available(mockExpiryDate) + outOfTimeFlow.value = false val action = awaitItem() assertIs(action) } @@ -188,8 +187,8 @@ class WelcomeViewModelTest { viewModel.uiState.test { // Default item awaitItem() - paymentAvailability.tryEmit(productsUnavailable) - serviceConnectionState.value = + paymentAvailabilityFlow.tryEmit(productsUnavailable) + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem().billingPaymentState assertIs(result) @@ -200,8 +199,8 @@ class WelcomeViewModelTest { fun testBillingProductsGenericErrorState() = runTest { // Arrange val paymentOtherError = PaymentAvailability.Error.Other(mockk()) - paymentAvailability.tryEmit(paymentOtherError) - serviceConnectionState.value = + paymentAvailabilityFlow.tryEmit(paymentOtherError) + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -215,8 +214,8 @@ class WelcomeViewModelTest { fun testBillingProductsBillingErrorState() = runTest { // Arrange val paymentBillingError = PaymentAvailability.Error.BillingUnavailable - paymentAvailability.value = paymentBillingError - serviceConnectionState.value = + paymentAvailabilityFlow.value = paymentBillingError + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -232,8 +231,8 @@ class WelcomeViewModelTest { val mockProduct: PaymentProduct = mockk() val expectedProductList = listOf(mockProduct) val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailability.value = productsAvailable - serviceConnectionState.value = + paymentAvailabilityFlow.value = productsAvailable + serviceConnectionStateFlow.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert @@ -244,46 +243,6 @@ class WelcomeViewModelTest { } } - @Test - fun testBillingUserCancelled() = runTest { - // Arrange - val result = PurchaseResult.Completed.Cancelled - purchaseResult.value = result - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - every { result.toPaymentDialogData() } returns null - - // Act, Assert - viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) } - } - - @Test - fun testBillingPurchaseSuccess() = runTest { - // Arrange - val result = PurchaseResult.Completed.Success - val expectedData: PaymentDialogData = mockk() - purchaseResult.value = result - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - every { result.toPaymentDialogData() } returns expectedData - - // Act, Assert - viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) } - } - - @Test - fun testStartBillingPayment() { - // Arrange - val mockProductId = ProductId("MOCK") - val mockActivityProvider = mockk<() -> Activity>() - - // Act - viewModel.startBillingPayment(mockProductId, mockActivityProvider) - - // Assert - coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } - } - companion object { private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" diff --git a/android/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 067a8c8d679f..c7ec54a5e86d 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.png b/android/docs/diagrams/nav_graph.png new file mode 100644 index 000000000000..2d6d6186c2d1 Binary files /dev/null and b/android/docs/diagrams/nav_graph.png differ diff --git a/android/docs/diagrams/nav_graph.puml b/android/docs/diagrams/nav_graph.puml new file mode 100644 index 000000000000..844a6b2570ae --- /dev/null +++ b/android/docs/diagrams/nav_graph.puml @@ -0,0 +1,32 @@ +@startuml +[*] --> splash +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 + +@enduml diff --git a/android/docs/diagrams/overview.puml b/android/docs/diagrams/overview.puml index d073e675938a..38cf89a38bdc 100644 --- a/android/docs/diagrams/overview.puml +++ b/android/docs/diagrams/overview.puml @@ -1,4 +1,4 @@ -@startuml overview-diagram +@startuml overview title Mullvad VPN Android app overview skinparam SequenceMessageAlign center diff --git a/android/docs/diagrams/update_graphs.sh b/android/docs/diagrams/update_graphs.sh new file mode 100755 index 000000000000..51d3b74b85ef --- /dev/null +++ b/android/docs/diagrams/update_graphs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +plantuml $SCRIPT_DIR/*.puml 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/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 0b690671ace7..836f72cadc2f 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -171,7 +171,9 @@ 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 @@