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