diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-06-09 16:43:14 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-06-09 16:43:14 +0200 |
| commit | 8b0b5ab45c3e0720797bd381d4b02e70cf4043f9 (patch) | |
| tree | 4d5d5fc018053cf664be5c41040f8755de07c55d | |
| parent | 87e716c551f563b6bf181bcef87a58bee0fb2599 (diff) | |
| parent | 1c58ad3fc58c1862526d912efc311e06956317fd (diff) | |
| download | mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.tar.xz mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.zip | |
Merge branch 'implement-payment-screen-with-3-months-droid-1947'
68 files changed, 2352 insertions, 2044 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt new file mode 100644 index 0000000000..095c15dafc --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt @@ -0,0 +1,372 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Density +import de.mannodermaus.junit5.compose.ComposeContext +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.Unit +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.payment.ProductIds +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.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalMaterial3Api::class) +class AddTimeBottomSheetTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + private fun ComposeContext.initBottomSheet( + state: Lc<Unit, AddTimeUiState> = Lc.Loading(Unit), + sheetState: SheetState = + SheetState( + skipPartiallyExpanded = true, + density = Density(1f), + initialValue = SheetValue.Expanded, + ), + onPurchaseBillingProductClick: (ProductId) -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, + onSitePaymentClick: () -> Unit = {}, + onRedeemVoucherClick: () -> Unit = {}, + onRetryFetchProducts: () -> Unit = {}, + resetPurchaseState: () -> Unit = {}, + closeSheetAndResetPurchaseState: (Boolean) -> Unit = {}, + closeBottomSheet: (animate: Boolean) -> Unit = {}, + ) { + setContentWithTheme { + AddTimeBottomSheetContent( + state = state, + sheetState = sheetState, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + closeBottomSheet = closeBottomSheet, + ) + } + } + + @Test + fun testBuyCreditClick() = + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + onSitePaymentClick = mockedClickHandler, + ) + + // Act + onNodeWithText(BUY_CREDIT_TEXT).performClick() + + // Assert + verify(exactly = 1) { mockedClickHandler.invoke() } + } + + @Test + fun testRedeemVoucherClick() = + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = true, + ) + .toLc(), + onRedeemVoucherClick = mockedClickHandler, + ) + + // Act + onNodeWithText("Redeem voucher").performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testShowBillingErrorPaymentButton() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = PaymentState.Error.Generic, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Failed to load products, please try again").assertExists() + } + + @Test + fun testShowBillingPaymentAvailable() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns null + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Add 30 days time ($10)").assertExists() + } + + @Test + fun testShowPendingPayment() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.PENDING + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Google Play payment pending, this might take some time").assertExists() + } + + @Test + fun testShowPendingPaymentInfoDialog() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.PENDING + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc(), + onPlayPaymentInfoClick = mockNavigateToVerificationPending, + ) + + // Act + onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick() + + // Assert + verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } + } + + @Test + fun testShowVerificationInProgress() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS + every { mockPaymentProduct.productId } returns ProductId(ProductIds.ThreeMonths) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Verifying purchase").assertExists() + } + + @Test + fun testOnPurchaseBillingProductClick() = + composeExtension.use { + // Arrange + val clickHandler: (ProductId) -> Unit = mockk(relaxed = true) + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + every { mockPaymentProduct.status } returns null + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc(), + onPurchaseBillingProductClick = clickHandler, + ) + + // Act + onNodeWithText("Add 30 days time ($10)").performClick() + + // Assert + verify { clickHandler.invoke(ProductId(ProductIds.OneMonth)) } + } + + @Test + fun testShowPurchaseCompleteDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = + PurchaseState.Success(ProductId(ProductIds.ThreeMonths)), + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Time added").assertExists() + onNodeWithText("90 days was added to your account.").assertExists() + } + + @Test + fun testShowVerificationErrorDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + AddTimeUiState( + purchaseState = PurchaseState.VerifyingPurchase, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Verifying purchase").assertExists() + } + + @Test + fun testShowFetchProductsErrorDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = PurchaseState.Error.OtherError(ProductId("ProductId")), + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText( + "We were unable to start the payment process, please make sure you have the latest version of Google Play." + ) + .assertExists() + } + + @Test + fun testDisableSitePayment() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText(BUY_CREDIT_TEXT).assertDoesNotExist() + } + + @Test + fun testShowInternetBlocked() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = true, + showSitePayment = true, + ) + .toLc() + ) + + // Assert + onNodeWithText("The app is blocking internet, please disconnect first").assertExists() + } + + companion object { + private const val BUY_CREDIT_TEXT = "Buy credit" + } +} 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 deleted file mode 100644 index 09a5e9dd72..0000000000 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithText -import de.mannodermaus.junit5.compose.ComposeContext -import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -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.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension - -class PaymentDialogTest { - @OptIn(ExperimentalTestApi::class) - @JvmField - @RegisterExtension - val composeExtension = createEdgeToEdgeComposeExtension() - - private fun ComposeContext.initDialog( - paymentDialogData: PaymentDialogData, - retryPurchase: (ProductId) -> Unit = {}, - onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit = {}, - ) { - setContentWithTheme { - PaymentDialog( - paymentDialogData = paymentDialogData, - retryPurchase = retryPurchase, - onCloseDialog = onCloseDialog, - ) - } - } - - @Test - fun testShowPurchaseCompleteDialog() = - composeExtension.use { - // Arrange - initDialog(paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()!!) - - // Assert - onNodeWithText("Time was successfully added").assertExists() - } - - @Test - fun testShowVerificationErrorDialog() = - composeExtension.use { - // Arrange - initDialog( - paymentDialogData = - PurchaseResult.Error.VerificationError(null).toPaymentDialogData()!! - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testShowFetchProductsErrorDialog() = - composeExtension.use { - // Arrange - initDialog( - paymentDialogData = - PurchaseResult.Error.FetchProductsError(ProductId(""), null) - .toPaymentDialogData()!! - ) - - // Assert - 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 cc8c8e9943..9794fefa1e 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 @@ -2,7 +2,6 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import de.mannodermaus.junit5.compose.ComposeContext @@ -10,38 +9,42 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber -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.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.AccountUiState +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @ExperimentalTestApi @OptIn(ExperimentalMaterial3Api::class) class AccountScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( - state: AccountUiState = AccountUiState.default(), + state: AccountUiState? = null, onCopyAccountNumber: (String) -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, - onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onManageDevicesClick: () -> Unit = {}, ) { @@ -49,13 +52,11 @@ class AccountScreenTest { AccountScreen( state = state, onCopyAccountNumber = onCopyAccountNumber, - onRedeemVoucherClick = onRedeemVoucherClick, - onManageAccountClick = onManageAccountClick, + onManageDevicesClick = onManageDevicesClick, onLogoutClick = onLogoutClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, onBackClick = onBackClick, - onManageDevicesClick = onManageDevicesClick, ) } } @@ -70,44 +71,17 @@ class AccountScreenTest { deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = false, ) ) // Assert - onNodeWithText("Redeem voucher").assertExists() onNodeWithText("Log out").assertExists() } @Test - fun testManageAccountClick() = - composeExtension.use { - // Arrange - val mockedClickHandler: () -> Unit = mockk(relaxed = true) - initScreen( - state = - AccountUiState( - deviceName = DUMMY_DEVICE_NAME, - accountNumber = DUMMY_ACCOUNT_NUMBER, - accountExpiry = null, - showSitePayment = true, - showLogoutLoading = false, - showManageAccountLoading = false, - ), - onManageAccountClick = mockedClickHandler, - ) - - // Act - onNodeWithText("Manage account").performClick() - - // Assert - verify(exactly = 1) { mockedClickHandler.invoke() } - } - - @Test - fun testRedeemVoucherClick() = + fun testLogoutClick() = composeExtension.use { // Arrange val mockedClickHandler: () -> Unit = mockk(relaxed = true) @@ -117,169 +91,38 @@ class AccountScreenTest { deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = false, ), - onRedeemVoucherClick = mockedClickHandler, + onLogoutClick = mockedClickHandler, ) // Act - onNodeWithText("Redeem voucher").performClick() + onNodeWithText("Log out").performClick() // Assert verify { mockedClickHandler.invoke() } } @Test - fun testLogoutClick() = + fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockedClickHandler: () -> Unit = mockk(relaxed = true) initScreen( state = AccountUiState( deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, - ), - onLogoutClick = mockedClickHandler, - ) - - // Act - onNodeWithText("Log out").performClick() - - // Assert - verify { mockedClickHandler.invoke() } - } - - @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = - AccountUiState.default().copy(billingPaymentState = PaymentState.Error.Billing) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + verificationPending = true, + ) ) // Assert onNodeWithText("Google Play payment pending").assertExists() } - @Test - fun testShowPendingPaymentInfoDialog() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - navigateToVerificationPendingDialog = mockNavigateToVerificationPending, - ) - - // Act - onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick() - - // Assert - verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } - } - - @Test - fun testShowVerificationInProgress() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - 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 - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() - - // Assert - verify { clickHandler.invoke(ProductId("PRODUCT_ID")) } - } - companion object { private const val DUMMY_DEVICE_NAME = "fake_name" private val DUMMY_ACCOUNT_NUMBER = AccountNumber("1234123412341234") 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 192daa7199..451c02309f 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 @@ -10,50 +10,53 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.TunnelState -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.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class OutOfTimeScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( state: OutOfTimeUiState = OutOfTimeUiState(), onDisconnectClick: () -> Unit = {}, - onSitePaymentClick: () -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, - onPurchaseBillingProductClick: (ProductId) -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, ) { setContentWithTheme { OutOfTimeScreen( state = state, onDisconnectClick = onDisconnectClick, - onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, ) } } @@ -70,7 +73,6 @@ class OutOfTimeScreenTest { substring = true, ) .assertDoesNotExist() - onNodeWithText("Buy credit").assertDoesNotExist() } @Test @@ -91,40 +93,6 @@ class OutOfTimeScreenTest { } @Test - fun testClickSitePaymentButton() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = OutOfTimeUiState(deviceName = "", showSitePayment = true), - onSitePaymentClick = mockClickListener, - ) - - // Act - onNodeWithText("Buy credit").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testClickRedeemVoucher() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = OutOfTimeUiState(deviceName = "", showSitePayment = true), - onRedeemVoucherClick = mockClickListener, - ) - - // Act - onNodeWithText("Redeem voucher").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test fun testClickDisconnect() = composeExtension.use { // Arrange @@ -147,77 +115,13 @@ class OutOfTimeScreenTest { } @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = PaymentState.Error.Billing, - ) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ) - ) - - // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ) - ) - - // Assert - onNodeWithText("Google Play payment pending").assertExists() - } - - @Test fun testShowPendingPaymentInfoDialog() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) + val mockOnPlayPaymentInfoClick: () -> Unit = mockk(relaxed = true) initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ), - navigateToVerificationPendingDialog = mockNavigateToVerificationPending, + state = OutOfTimeUiState(showSitePayment = true, verificationPending = true), + onPlayPaymentInfoClick = mockOnPlayPaymentInfoClick, ) // Act @@ -225,52 +129,16 @@ class OutOfTimeScreenTest { onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).assertExists() // Assert - verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } + verify(exactly = 1) { mockOnPlayPaymentInfoClick.invoke() } } @Test fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( - state = - OutOfTimeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - showSitePayment = true, - ) - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - 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 - initScreen( - state = - OutOfTimeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - showSitePayment = true, - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() + initScreen(state = OutOfTimeUiState(showSitePayment = true, verificationPending = true)) // Assert - verify { clickHandler(ProductId("PRODUCT_ID")) } + onNodeWithText("Google Play payment pending").assertExists() } } 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 b3ac54fb73..cf25afee16 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 @@ -9,52 +9,56 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.TunnelState -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.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class WelcomeScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( - state: WelcomeUiState = WelcomeUiState(), - onSitePaymentClick: () -> Unit = {}, + state: Lc<Unit, WelcomeUiState> = Lc.Loading(Unit), onRedeemVoucherClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {}, onDisconnectClick: () -> Unit = {}, navigateToDeviceInfoDialog: () -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, ) { setContentWithTheme { WelcomeScreen( state = state, - onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, navigateToDeviceInfoDialog = navigateToDeviceInfoDialog, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, onDisconnectClick = onDisconnectClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, ) } } @@ -82,7 +86,6 @@ class WelcomeScreenTest { substring = true, ) .assertDoesNotExist() - onNodeWithText("Buy credit").assertDoesNotExist() } @Test @@ -91,108 +94,38 @@ class WelcomeScreenTest { // Arrange val rawAccountNumber = AccountNumber("1111222233334444") val expectedAccountNumber = "1111 2222 3333 4444" - initScreen(state = WelcomeUiState(accountNumber = rawAccountNumber)) - - // Assert - onNodeWithText(expectedAccountNumber).assertExists() - } - - @Test - fun testClickSitePaymentButton() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = WelcomeUiState(showSitePayment = true), - onSitePaymentClick = mockClickListener, - ) - - // Act - onNodeWithText("Buy credit").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testClickRedeemVoucher() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen(state = WelcomeUiState(), onRedeemVoucherClick = mockClickListener) - - // Act - onNodeWithText("Redeem voucher").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = WelcomeUiState().copy(billingPaymentState = PaymentState.Error.Billing) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + tunnelState = TunnelState.Disconnected(), + accountNumber = rawAccountNumber, + deviceName = null, + showSitePayment = false, + verificationPending = false, + ) + .toLc() ) // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Google Play payment pending").assertExists() + onNodeWithText(expectedAccountNumber).assertExists() } @Test fun testShowPendingPaymentInfoDialog() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING val mockShowPendingInfo = mockk<() -> Unit>(relaxed = true) initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - navigateToVerificationPendingDialog = mockShowPendingInfo, + tunnelState = TunnelState.Disconnected(), + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = true, + ) + .toLc(), + onPlayPaymentInfoClick = mockShowPendingInfo, ) // Act @@ -206,45 +139,20 @@ class WelcomeScreenTest { fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + tunnelState = TunnelState.Disconnected(), + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = true, + ) + .toLc() ) // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - 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 - initScreen( - state = - WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() - - // Assert - verify { clickHandler(ProductId("PRODUCT_ID")) } + onNodeWithText("Google Play payment pending").assertExists() } @Test @@ -255,7 +163,15 @@ class WelcomeScreenTest { val tunnelState: TunnelState = mockk(relaxed = true) every { tunnelState.isSecured() } returns true initScreen( - state = WelcomeUiState(tunnelState = tunnelState), + state = + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = false, + ) + .toLc(), onDisconnectClick = clickHandler, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index 250ba7da99..8aa7d762b5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.button +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -12,12 +13,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -204,6 +207,51 @@ fun PrimaryTextButton( } @Composable +fun NegativeOutlinedButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + colors: ButtonColors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onError, + disabledContentColor = MaterialTheme.colorScheme.onError.copy(alpha = Alpha20), + ), + border: BorderStroke = + BorderStroke( + width = Dimens.outLineButtonBorderWidth, + color = MaterialTheme.colorScheme.error, + ), + shape: Shape = MaterialTheme.shapes.large, + isEnabled: Boolean = true, + isLoading: Boolean = false, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val hasIcon = leadingIcon != null || trailingIcon != null + OutlinedButton( + onClick = onClick, + modifier = modifier.wrapContentHeight().width(IntrinsicSize.Max), + colors = colors, + enabled = !isLoading && isEnabled, + border = border, + contentPadding = + if (hasIcon) { + PaddingValues(vertical = Dimens.buttonSpacing) + } else { + ButtonDefaults.TextButtonContentPadding + }, + shape = shape, + ) { + BaseButtonContent( + text = text, + isLoading = isLoading, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) + } +} + +@Composable private fun BaseButton( onClick: () -> Unit, colors: ButtonColors, @@ -238,6 +286,22 @@ private fun BaseButton( } @Composable +fun SmallPrimaryButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + colors: ButtonColors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = Alpha20), + disabledContainerColor = MaterialTheme.colorScheme.primaryDisabled, + ), +) { + Button(onClick = onClick, modifier = modifier, colors = colors) { Text(text = text) } +} + +@Composable private fun RowScope.BaseButtonContent( text: String, isLoading: Boolean, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt index 0a14272afd..46d5cdb2ae 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt @@ -26,11 +26,17 @@ private fun PreviewSitePaymentButton() { } @Composable -fun SitePaymentButton(onClick: () -> Unit, isEnabled: Boolean, modifier: Modifier = Modifier) { +fun SitePaymentButton( + onClick: () -> Unit, + isEnabled: Boolean, + modifier: Modifier = Modifier, + isLoading: Boolean = false, +) { ExternalButton( onClick = onClick, modifier = modifier, isEnabled = isEnabled, + isLoading = isLoading, text = stringResource(id = R.string.buy_credit), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt index 329f66afb4..4bef6416ee 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt @@ -1,10 +1,13 @@ package net.mullvad.mullvadvpn.compose.cell +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -14,18 +17,35 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.component.SpacedColumn import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable private fun PreviewIconCell() { - AppTheme { IconCell(imageVector = Icons.Default.Add, title = "Add") } + AppTheme { + SpacedColumn { + IconCell(imageVector = Icons.Default.Add, title = "Add") + IconCell( + imageVector = Icons.Default.Remove, + title = "Remove", + endIcon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, + ) + } + } } @Composable fun IconCell( imageVector: ImageVector?, + endIcon: @Composable ColumnScope.() -> Unit = {}, title: String, modifier: Modifier = Modifier, contentDescription: String? = null, @@ -37,7 +57,7 @@ fun IconCell( ) { BaseCell( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { imageVector?.let { Icon( imageVector = imageVector, @@ -49,6 +69,7 @@ fun IconCell( BaseCellTitle(title = title, style = titleStyle, textColor = titleColor) } }, + bodyView = endIcon, onCellClicked = onClick, background = background, isRowEnabled = enabled, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt new file mode 100644 index 0000000000..ef92d131f5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt @@ -0,0 +1,506 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Redeem +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Density +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.SmallPrimaryButton +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.preview.AddMoreTimeUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.payment.ProductIds.OneMonth +import net.mullvad.mullvadvpn.lib.payment.ProductIds.ThreeMonths +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.viewmodel.AddMoreTimeSideEffect +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Preview( + "Loading|oss|LoadingSitePayment|" + + "PaymentLoading|NoPayment|NoProductsFound|PaymentAvailable|PaymentPending|PaymentError" +) +@Composable +private fun PreviewPaymentBottomSheet( + @PreviewParameter(AddMoreTimeUiStatePreviewParameterProvider::class) + state: Lc<Unit, AddTimeUiState> +) { + AppTheme { + AddTimeBottomSheetContent( + state = state, + sheetState = + SheetState( + skipPartiallyExpanded = true, + density = Density(1f), + initialValue = SheetValue.Expanded, + ), + onPurchaseBillingProductClick = {}, + onPlayPaymentInfoClick = {}, + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + closeBottomSheet = {}, + onRetryFetchProducts = {}, + resetPurchaseState = {}, + closeSheetAndResetPurchaseState = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTimeBottomSheet( + visible: Boolean, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onHideBottomSheet: () -> Unit, +) { + val viewModel: AddTimeViewModel = koinViewModel<AddTimeViewModel>() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> + if (animate) { + scope.launch { sheetState.hide() }.invokeOnCompletion { onHideBottomSheet() } + } else { + onHideBottomSheet() + } + } + + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { sideEffect -> + when (sideEffect) { + is AddMoreTimeSideEffect.OpenAccountManagementPageInBrowser -> { + openAccountPage(sideEffect.token) + onCloseBottomSheet(true) + } + } + } + + val activity = LocalActivity.current + if (visible) { + AddTimeBottomSheetContent( + state = uiState, + sheetState = sheetState, + onPurchaseBillingProductClick = { + viewModel.startBillingPayment(productId = it, activityProvider = { activity!! }) + }, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = viewModel::onManageAccountClick, + onRetryFetchProducts = viewModel::fetchPaymentAvailability, + onRedeemVoucherClick = onRedeemVoucherClick, + resetPurchaseState = { viewModel.onClosePurchaseResultDialog(false) }, + closeSheetAndResetPurchaseState = { + viewModel.onClosePurchaseResultDialog(it) + onCloseBottomSheet(true) + }, + closeBottomSheet = onCloseBottomSheet, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTimeBottomSheetContent( + state: Lc<Unit, AddTimeUiState>, + sheetState: SheetState, + onPurchaseBillingProductClick: (ProductId) -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + val backgroundColor = MaterialTheme.colorScheme.surfaceContainer + val onBackgroundColor = MaterialTheme.colorScheme.onSurface + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + ) { + when (state) { + is Lc.Loading -> + Loading(backgroundColor = backgroundColor, onBackgroundColor = onBackgroundColor) + is Lc.Content -> + Content( + state = state.value, + internetBlocked = state.value.tunnelStateBlocked, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + closeBottomSheet = closeBottomSheet, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + ) + } + } +} + +@Composable +private fun Content( + state: AddTimeUiState, + internetBlocked: Boolean, + backgroundColor: Color, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, +) { + AnimatedContent(targetState = state) { state -> + Column { + if (state.purchaseState != null) { + PurchaseState( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + purchaseState = state.purchaseState, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + ) + } else { + Products( + billingPaymentState = state.billingPaymentState, + showSitePayment = state.showSitePayment, + internetBlocked = internetBlocked, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + closeBottomSheet = closeBottomSheet, + ) + } + } + } +} + +@Composable +private fun ColumnScope.PurchaseState( + backgroundColor: Color, + onBackgroundColor: Color, + purchaseState: PurchaseState, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, +) { + when (purchaseState) { + // Fetching products and obfuscated id loading state + PurchaseState.Connecting -> { + PurchaseStateLoading(title = stringResource(R.string.connecting)) + } + PurchaseState.VerificationStarted -> { + PurchaseStateLoading(title = stringResource(R.string.loading_verifying)) + } + // Pending state + PurchaseState.VerifyingPurchase -> { + PurchaseStateVerification( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + closeSheet = closeSheetAndResetPurchaseState, + ) + } + // Success state + is PurchaseState.Success -> { + PurchaseStateSuccess( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + productId = purchaseState.productId, + onSuccessfulPurchase = closeSheetAndResetPurchaseState, + ) + } + // Error states + is PurchaseState.Error.TransactionIdError -> { + PurchaseStateError( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + title = stringResource(R.string.payment_obfuscation_id_error_dialog_title), + message = stringResource(R.string.payment_obfuscation_id_error_dialog_message), + resetPurchaseState = resetPurchaseState, + ) + } + is PurchaseState.Error.OtherError -> { + PurchaseStateError( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + title = stringResource(R.string.payment_billing_error_dialog_title), + message = stringResource(R.string.payment_billing_error_dialog_message), + resetPurchaseState = resetPurchaseState, + ) + } + } +} + +@Composable +private fun PurchaseStateVerification( + onBackgroundColor: Color, + backgroundColor: Color, + closeSheet: (Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.verifying_purchase), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = stringResource(id = R.string.payment_pending_dialog_message), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SmallPrimaryButton( + text = stringResource(R.string.close), + onClick = { closeSheet(false) }, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) + } +} + +@Composable +private fun PurchaseStateLoading(title: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(all = Dimens.sideMargin), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + MullvadLinearProgressIndicator() + } +} + +@Composable +private fun PurchaseStateSuccess( + onBackgroundColor: Color, + backgroundColor: Color, + productId: ProductId, + onSuccessfulPurchase: (Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.time_added), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = + when (productId.value) { + OneMonth -> stringResource(R.string.days_were_added_30) + ThreeMonths -> stringResource(R.string.days_were_added_90) + else -> { + error("Unknown product: $productId") + } + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SmallPrimaryButton( + text = stringResource(R.string.close), + onClick = { onSuccessfulPurchase(true) }, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) + } +} + +@Composable +private fun ColumnScope.PurchaseStateError( + onBackgroundColor: Color, + backgroundColor: Color, + title: String, + message: String, + resetPurchaseState: () -> Unit, +) { + SheetTitle( + title = title, + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) + Text( + text = message, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = Dimens.sideMargin), + ) + SmallPrimaryButton( + text = stringResource(android.R.string.ok), + onClick = resetPurchaseState, + modifier = Modifier.padding(top = Dimens.mediumPadding).align(Alignment.CenterHorizontally), + ) +} + +@Composable +private fun Products( + billingPaymentState: PaymentState?, + internetBlocked: Boolean, + showSitePayment: Boolean, + backgroundColor: Color, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.add_time), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + billingPaymentState?.let { + PlayPayment( + modifier = Modifier.fillMaxWidth(), + billingPaymentState = billingPaymentState, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onInfoClick = onPlayPaymentInfoClick, + onRetryFetchProducts = onRetryFetchProducts, + ) + } + if (showSitePayment) { + if (internetBlocked) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellStartPadding), + ) { + Icon( + modifier = Modifier.size(Dimens.smallIconSize), + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.app_is_blocking_internet), + modifier = Modifier.padding(start = Dimens.miniPadding), + ) + } + } + IconCell( + imageVector = Icons.Outlined.Sell, + title = stringResource(id = R.string.buy_credit), + onClick = { onSitePaymentClick() }, + titleColor = + onBackgroundColor.copy( + alpha = if (internetBlocked) AlphaDisabled else AlphaVisible + ), + background = + backgroundColor.copy(alpha = if (internetBlocked) AlphaDisabled else AlphaVisible), + enabled = !internetBlocked, + endIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + tint = + onBackgroundColor.copy( + alpha = if (internetBlocked) AlphaDisabled else AlphaVisible + ), + contentDescription = null, + ) + }, + ) + HorizontalDivider( + modifier = Modifier.height(Dimens.thinBorderWidth), + color = onBackgroundColor, + ) + } + IconCell( + imageVector = Icons.Default.Redeem, + title = stringResource(id = R.string.redeem_voucher), + titleColor = onBackgroundColor, + onClick = { + onRedeemVoucherClick() + closeBottomSheet(true) + }, + background = backgroundColor, + ) +} + +@Composable +private fun ColumnScope.Loading(onBackgroundColor: Color, backgroundColor: Color) { + SheetTitle( + title = stringResource(id = R.string.add_time), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally)) +} + +@Composable +private fun SheetTitle(title: String, onBackgroundColor: Color, backgroundColor: Color) { + HeaderCell(text = title, background = backgroundColor) + HorizontalDivider( + color = onBackgroundColor, + modifier = Modifier.padding(horizontal = Dimens.mediumPadding), + ) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt new file mode 100644 index 0000000000..8a99ebbf59 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun MullvadLinearProgressIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onPrimary, + trackColor: Color = MaterialTheme.colorScheme.primary, +) { + LinearProgressIndicator( + modifier = modifier.fillMaxWidth(), + color = color, + trackColor = trackColor, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt index acd8f00443..b71079be67 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt @@ -1,190 +1,244 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton 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.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.button.SmallPrimaryButton +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.preview.PlayPaymentPaymentStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.payment.ProductIds 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.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG -@Preview +@Preview( + "Loading|NoPayment|NoProductsFound|Error.Generic|Error.Billing" + + "|PaymentAvailable|PaymentAvailable.Pending|PaymentAvailable.VerificationInProgress" +) @Composable -private fun PreviewPlayPaymentPaymentAvailable() { +private fun PreviewPlayPayment( + @PreviewParameter(PlayPaymentPaymentStatePreviewParameterProvider::class) state: PaymentState +) { AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = null, - ) - ) - ), + billingPaymentState = state, + onBackgroundColor = MaterialTheme.colorScheme.onSurface, onPurchaseBillingProductClick = {}, + onRetryFetchProducts = {}, onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), ) } } } -@Preview @Composable -private fun PreviewPlayPaymentLoading() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = PaymentState.Loading, - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), +fun PlayPayment( + billingPaymentState: PaymentState, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onRetryFetchProducts: () -> Unit, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (billingPaymentState) { + PaymentState.Loading -> { + Loading(modifier = modifier) + } + PaymentState.NoPayment, + PaymentState.NoProductsFounds -> { + // Show nothing + return + } + is PaymentState.PaymentAvailable -> { + PaymentAvailable( + modifier = modifier, + billingPaymentState = billingPaymentState, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onInfoClick = onInfoClick, ) } - } -} - -@Preview -@Composable -private fun PreviewPlayPaymentPaymentPending() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = PaymentStatus.PENDING, - ) - ) - ), - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), + is PaymentState.Error.Generic -> { + Error( + modifier = modifier, + message = stringResource(id = R.string.failed_to_load_products), + retryFetchProducts = onRetryFetchProducts, + ) + } + is PaymentState.Error.Billing -> { + Error( + modifier = modifier, + message = stringResource(id = R.string.in_app_products_unavailable), + retryFetchProducts = onRetryFetchProducts, ) } } + HorizontalDivider(color = onBackgroundColor, thickness = Dimens.thinBorderWidth) } -@Preview @Composable -private fun PreviewPlayPaymentVerificationInProgress() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = PaymentStatus.VERIFICATION_IN_PROGRESS, - ) - ) - ), - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), - ) - } +private fun Loading(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = stringResource(id = R.string.loading_products), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + MullvadLinearProgressIndicator() } } @Composable -fun PlayPayment( - billingPaymentState: PaymentState, +private fun PaymentAvailable( + billingPaymentState: PaymentState.PaymentAvailable, onPurchaseBillingProductClick: (ProductId) -> Unit, onInfoClick: () -> Unit, modifier: Modifier = Modifier, ) { - when (billingPaymentState) { - PaymentState.Loading -> { - Column(modifier = modifier.fillMaxWidth()) { - MullvadCircularProgressIndicatorSmall(modifier = modifier) + val statusMessage = billingPaymentState.products.status()?.message() + Column( + modifier = + modifier + .clickable(enabled = statusMessage != null, onClick = onInfoClick) + .testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG) + ) { + val enabled = statusMessage == null + statusMessage?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellStartPadding), + ) { + Icon( + modifier = Modifier.size(Dimens.smallIconSize), + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = statusMessage, + modifier = Modifier.padding(start = Dimens.miniPadding), + ) } } - PaymentState.NoPayment, - PaymentState.NoProductsFounds -> { - // Show nothing - } - is PaymentState.PaymentAvailable -> { + Column { billingPaymentState.products.forEach { product -> - Column(modifier = modifier) { - val statusMessage = - when (product.status) { - PaymentStatus.PENDING -> - stringResource(id = R.string.payment_status_pending) - PaymentStatus.VERIFICATION_IN_PROGRESS -> - stringResource(id = R.string.verifying_purchase) - else -> null - } - statusMessage?.let { - Row(verticalAlignment = Alignment.Bottom) { - Text( - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - text = statusMessage, - modifier = Modifier.padding(bottom = Dimens.smallPadding), - ) - IconButton( - onClick = onInfoClick, - modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, + IconCell( + background = MaterialTheme.colorScheme.surfaceContainer, + titleColor = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaDisabled) + }, + imageVector = Icons.Outlined.Sell, + title = + when (product.productId.value) { + ProductIds.OneMonth -> + stringResource( + id = R.string.add_30_days_time_x, + product.price.value, ) + ProductIds.ThreeMonths -> + stringResource( + id = R.string.add_90_days_time_x, + product.price.value, + ) + else -> { + // We have somehow requested a product that is not supported + error("ProductId ${product.productId.value} is not supported") } - } - } - VariantButton( - text = - stringResource(id = R.string.add_30_days_time_x, product.price.value), - onClick = { onPurchaseBillingProductClick(product.productId) }, - isEnabled = product.status == null, - ) - } - } - } - // Show the button without the price - is PaymentState.Error -> { - Column(modifier = modifier) { - VariantButton( - text = stringResource(id = R.string.add_30_days_time), - onClick = { onPurchaseBillingProductClick(ProductId(ProductIds.OneMonth)) }, + }, + endIcon = { + Image( + painter = + painterResource( + R.drawable.google_pay_primary_logo_logo_svgrepo_com + ), + contentDescription = null, + modifier = + Modifier.height(Dimens.payIconHeight) + .background( + MaterialTheme.colorScheme.onSurface, + MaterialTheme.shapes.extraLarge, + ) + .padding(all = Dimens.miniPadding), + ) + }, + onClick = { onPurchaseBillingProductClick(product.productId) }, + enabled = enabled, ) } } } } + +@Composable +private fun Error(modifier: Modifier, message: String, retryFetchProducts: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier.padding(vertical = Dimens.screenTopMargin, horizontal = Dimens.sideMargin), + ) { + Text( + text = message, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = Dimens.smallPadding), + ) + SmallPrimaryButton(text = stringResource(R.string.retry), onClick = retryFetchProducts) + } +} + +private fun List<PaymentProduct>.status(): PaymentStatus? { + return this.firstOrNull { it.status != null }?.status +} + +@Composable +private fun PaymentStatus.message(): String = + when (this) { + PaymentStatus.PENDING -> stringResource(id = R.string.payment_status_pending_long) + + PaymentStatus.VERIFICATION_IN_PROGRESS -> stringResource(id = R.string.verifying_purchase) + } 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 deleted file mode 100644 index bf9de7e449..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ /dev/null @@ -1,219 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog.payment - -import androidx.compose.material3.AlertDialog -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.getValue -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.tooling.preview.Preview -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -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.MullvadCircularProgressIndicatorLarge -import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.theme.AppTheme -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 -private fun PreviewPaymentDialogPurchaseCompleted() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.payment_completed_dialog_title, - message = R.string.payment_completed_dialog_message, - icon = PaymentDialogIcon.SUCCESS, - confirmAction = PaymentDialogAction.Close, - successfulPayment = true, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogPurchasePending() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.verifying_purchase, - message = R.string.payment_pending_dialog_message, - confirmAction = PaymentDialogAction.Close, - closeOnDismiss = true, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogGenericError() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.error_occurred, - message = R.string.try_again, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogLoading() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.connecting, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogPaymentAvailabilityError() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.payment_billing_error_dialog_title, - message = R.string.payment_billing_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = PaymentDialogAction.RetryPurchase(productId = ProductId("test")), - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Destination<RootGraph>(style = DestinationStyle.Dialog::class) -@Composable -fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boolean>) { - val vm = koinViewModel<PaymentViewModel>() - val state by vm.uiState.collectAsStateWithLifecycle() - - CollectSideEffectWithLifecycle(vm.uiSideEffect) { sideEffect -> - when (sideEffect) { - PaymentUiSideEffect.PaymentCancelled -> resultBackNavigator.navigateBack(result = false) - } - } - - val context = LocalContext.current - LaunchedEffect(Unit) { vm.startBillingPayment(productId) { context.getActivity()!! } } - - val dialogData = state.paymentDialogData - if (dialogData != null) { - PaymentDialog( - paymentDialogData = dialogData, - retryPurchase = { vm.startBillingPayment(it) { context.getActivity()!! } }, - onCloseDialog = { resultBackNavigator.navigateBack(result = it) }, - ) - } -} - -@Composable -fun PaymentDialog( - paymentDialogData: PaymentDialogData, - retryPurchase: (ProductId) -> Unit, - onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit, -) { - val clickResolver: (action: PaymentDialogAction) -> Unit = { - when (it) { - is PaymentDialogAction.RetryPurchase -> retryPurchase(it.productId) - is PaymentDialogAction.Close -> onCloseDialog(paymentDialogData.successfulPayment) - } - } - AlertDialog( - icon = { - when (paymentDialogData.icon) { - PaymentDialogIcon.SUCCESS -> - Icon( - painter = painterResource(id = R.drawable.icon_success), - contentDescription = null, - ) - PaymentDialogIcon.FAIL -> - Icon( - painter = painterResource(id = R.drawable.icon_fail), - contentDescription = null, - ) - PaymentDialogIcon.LOADING -> MullvadCircularProgressIndicatorLarge() - else -> {} - } - }, - title = { - paymentDialogData.title?.let { - Text( - text = stringResource(id = paymentDialogData.title), - style = MaterialTheme.typography.headlineSmall, - ) - } - }, - text = - paymentDialogData.message?.let { - { - Text( - text = stringResource(id = paymentDialogData.message), - style = MaterialTheme.typography.bodySmall, - ) - } - }, - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - iconContentColor = Color.Unspecified, - textContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - onDismissRequest = { - if (paymentDialogData.closeOnDismiss) { - onCloseDialog(paymentDialogData.successfulPayment) - } - }, - dismissButton = { - paymentDialogData.dismissAction?.let { - PrimaryButton( - text = stringResource(id = it.message), - onClick = { clickResolver(it) }, - ) - } - }, - confirmButton = { - paymentDialogData.confirmAction?.let { - PrimaryButton( - text = stringResource(id = it.message), - onClick = { clickResolver(it) }, - ) - } - }, - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt deleted file mode 100644 index 2bdcda0507..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt +++ /dev/null @@ -1,26 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog.payment - -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.payment.model.ProductId - -data class PaymentDialogData( - val title: Int? = null, - val message: Int? = null, - val icon: PaymentDialogIcon? = null, - val confirmAction: PaymentDialogAction? = null, - val dismissAction: PaymentDialogAction? = null, - val closeOnDismiss: Boolean = true, - val successfulPayment: Boolean = false, -) - -sealed class PaymentDialogAction(val message: Int) { - data object Close : PaymentDialogAction(R.string.got_it) - - data class RetryPurchase(val productId: ProductId) : PaymentDialogAction(R.string.try_again) -} - -enum class PaymentDialogIcon { - SUCCESS, - FAIL, - LOADING, -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt index f40d0697ab..6b981d5d7d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt @@ -3,62 +3,38 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.AccountNumber -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.util.Lc +import net.mullvad.mullvadvpn.util.toLc import net.mullvad.mullvadvpn.viewmodel.AccountUiState -class AccountUiStatePreviewParameterProvider : PreviewParameterProvider<AccountUiState> { +class AccountUiStatePreviewParameterProvider : PreviewParameterProvider<Lc<Unit, AccountUiState>> { override val values = sequenceOf( + Lc.Loading(Unit), AccountUiState( - deviceName = "Test Name", - accountNumber = AccountNumber("1234123412341234"), - accountExpiry = - ZonedDateTime.parse( - "2050-12-01T00:00:00.000Z", - DateTimeFormatter.ISO_ZONED_DATE_TIME, - ), - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable( - listOf( - PaymentProduct( - ProductId("productId"), - price = ProductPrice("34 SEK"), - status = null, - ), - PaymentProduct( - ProductId("productId_pending"), - price = ProductPrice("34 SEK"), - status = PaymentStatus.PENDING, - ), - ) - ), - showLogoutLoading = false, - showManageAccountLoading = false, - ) - ) + generateOtherStates() - - private fun generateOtherStates(): Sequence<AccountUiState> = - sequenceOf( - PaymentState.Loading, - PaymentState.NoPayment, - PaymentState.NoProductsFounds, - PaymentState.Error.Billing, - ) - .map { state -> - AccountUiState( deviceName = "Test Name", accountNumber = AccountNumber("1234123412341234"), - accountExpiry = null, - showSitePayment = false, - billingPaymentState = state, + accountExpiry = + ZonedDateTime.parse( + "2050-12-01T00:00:00.000Z", + DateTimeFormatter.ISO_ZONED_DATE_TIME, + ), showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = true, + ) + .toLc(), + AccountUiState( + deviceName = "Test Name", + accountNumber = AccountNumber("1234123412341234"), + accountExpiry = + ZonedDateTime.parse( + "2050-12-01T00:00:00.000Z", + DateTimeFormatter.ISO_ZONED_DATE_TIME, + ), + showLogoutLoading = true, + verificationPending = false, ) - } + .toLc(), + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..1a2f5a7024 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +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.util.Lc +import net.mullvad.mullvadvpn.util.toLc + +class AddMoreTimeUiStatePreviewParameterProvider : + PreviewParameterProvider<Lc<Unit, AddTimeUiState>> { + override val values: Sequence<Lc<Unit, AddTimeUiState>> = + sequenceOf( + Lc.Loading(Unit), + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + ) + + generatePaymentStates().map { state -> + AddTimeUiState( + purchaseState = null, + billingPaymentState = state, + showSitePayment = false, + tunnelStateBlocked = false, + ) + .toLc() + } + + private fun generatePaymentStates(): Sequence<PaymentState> = + sequenceOf( + PaymentState.Loading, + PaymentState.NoPayment, + PaymentState.NoProductsFounds, + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = null, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.PENDING, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + PaymentState.Error.Billing, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..04a5422238 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.PaymentState +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 + +class PlayPaymentPaymentStatePreviewParameterProvider : PreviewParameterProvider<PaymentState> { + override val values: Sequence<PaymentState> = + sequenceOf(PaymentState.Loading, PaymentState.Error.Generic, PaymentState.Error.Billing) + + sequenceOf( + // Products available + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = null, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + // Product pending + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.PENDING, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + // Product verification in progress + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.VERIFICATION_IN_PROGRESS, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt index 5b4028e6ca..5a26fd4b33 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -6,23 +6,22 @@ import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.ModelOwnership import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc class SelectLocationsUiStatePreviewParameterProvider : PreviewParameterProvider<Lc<Unit, SelectLocationUiState>> { override val values = sequenceOf( Lc.Loading(Unit), - Lc.Content( - SelectLocationUiState( + SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, relayListType = RelayListType.EXIT, isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = listOf( FilterChip.Ownership(ownership = ModelOwnership.Rented), @@ -33,18 +32,16 @@ class SelectLocationsUiStatePreviewParameterProvider : isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = emptyList(), multihopEnabled = true, relayListType = RelayListType.ENTRY, isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = listOf( FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), @@ -55,7 +52,7 @@ class SelectLocationsUiStatePreviewParameterProvider : isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), + .toLc(), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt index 79e7ae0069..a3d93eea33 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt @@ -1,25 +1,32 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc -class WelcomeScreenUiStatePreviewParameterProvider : PreviewParameterProvider<WelcomeUiState> { +class WelcomeScreenUiStatePreviewParameterProvider : + PreviewParameterProvider<Lc<Unit, WelcomeUiState>> { override val values = sequenceOf( + Lc.Loading(Unit), WelcomeUiState( - tunnelState = TunnelStatePreviewData.generateDisconnectedState(), - accountNumber = AccountNumber("4444555566667777"), - deviceName = "Happy Mole", - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf(PaymentProduct(ProductId("product"), ProductPrice("$44"), null)) - ), - ) + tunnelState = TunnelStatePreviewData.generateDisconnectedState(), + accountNumber = AccountNumber("4444555566667777"), + deviceName = "Happy Mole", + showSitePayment = false, + verificationPending = true, + ) + .toLc(), + WelcomeUiState( + tunnelState = + TunnelStatePreviewData.generateConnectedState(featureIndicators = 1, false), + accountNumber = AccountNumber("4444555566667777"), + deviceName = "Happy Mole", + showSitePayment = true, + verificationPending = false, + ) + .toLc(), ) } 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 dfd1f2d8f7..b7ce1ca5d4 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 @@ -8,15 +8,22 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info 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.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.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -30,27 +37,21 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.LoginDestination import com.ramcosta.composedestinations.generated.destinations.ManageDevicesDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import java.time.ZonedDateTime import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.ExternalButton -import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.NegativeOutlinedButton import net.mullvad.mullvadvpn.compose.button.PrimaryTextButton -import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView import net.mullvad.mullvadvpn.compose.component.InformationView import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton -import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.AccountUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.transitions.AccountTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle @@ -58,30 +59,38 @@ import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.common.util.toExpiryDateString -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.ui.tag.MANAGE_DEVICES_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.AccountUiState import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) -@Preview("PaymentAvailable|Loading|NoPayment|NoProductsFound|Error.Billing") +@Preview("Loading|Content|LogoutLoading") @Composable private fun PreviewAccountScreen( - @PreviewParameter(AccountUiStatePreviewParameterProvider::class) state: AccountUiState + @PreviewParameter(AccountUiStatePreviewParameterProvider::class) state: Lc<Unit, AccountUiState> ) { - AppTheme { AccountScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}, {}, {}, {}) } + AppTheme { + AccountScreen( + state = state.contentOrNull(), + snackbarHostState = SnackbarHostState(), + onCopyAccountNumber = {}, + onManageDevicesClick = {}, + onLogoutClick = {}, + onRedeemVoucherClick = {}, + onPlayPaymentInfoClick = {}, + onBackClick = {}, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Destination<RootGraph>(style = AccountTransition::class) @Composable -fun Account( - navigator: DestinationsNavigator, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun Account(navigator: DestinationsNavigator) { val vm = koinViewModel<AccountViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() @@ -109,46 +118,34 @@ fun Account( } } - playPaymentResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) - } - } - AccountScreen( - state = state, + state = state.contentOrNull(), snackbarHostState = snackbarHostState, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onManageDevicesClick = dropUnlessResumed { - state.accountNumber?.let { navigator.navigate(ManageDevicesDestination(it)) } + state.contentOrNull()?.accountNumber?.let { + navigator.navigate(ManageDevicesDestination(it)) + } }, - onManageAccountClick = vm::onManageAccountClick, onLogoutClick = vm::onLogoutClick, onCopyAccountNumber = vm::onCopyAccountNumber, - onBackClick = dropUnlessResumed { navigator.navigateUp() }, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, ) } @ExperimentalMaterial3Api @Composable fun AccountScreen( - state: AccountUiState, + state: AccountUiState?, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onCopyAccountNumber: (String) -> Unit, - onRedeemVoucherClick: () -> Unit, - onManageAccountClick: () -> Unit, onManageDevicesClick: () -> Unit, onLogoutClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, onBackClick: () -> Unit, ) { // This will enable SECURE_FLAG while this screen is visible to preview screenshot @@ -159,6 +156,16 @@ fun AccountScreen( navigationIcon = { NavigateCloseIconButton(onBackClick) }, snackbarHostState = snackbarHostState, ) { modifier -> + var addTimeBottomSheetState by remember { mutableStateOf<Boolean>(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } + Column( horizontalAlignment = Alignment.Start, modifier = @@ -172,50 +179,30 @@ fun AccountScreen( modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize(), ) { DeviceNameRow( - deviceName = state.deviceName ?: "", + deviceName = state?.deviceName ?: "", onManageDevicesClick = onManageDevicesClick, ) AccountNumberRow( - accountNumber = state.accountNumber?.value ?: "", + accountNumber = state?.accountNumber?.value ?: "", onCopyAccountNumber, ) - PaidUntilRow(accountExpiry = state.accountExpiry) - } - - Spacer(modifier = Modifier.weight(1f)) - - state.billingPaymentState?.let { - PlayPayment( - billingPaymentState = state.billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = navigateToVerificationPendingDialog, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - ) - } - - if (state.showSitePayment) { - ExternalButton( - text = stringResource(id = R.string.manage_account), - onClick = onManageAccountClick, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - isLoading = state.showManageAccountLoading, + PaidUntilRow( + accountExpiry = state?.accountExpiry, + verificationPending = state?.verificationPending == true, + onOpenPaymentScreen = { addTimeBottomSheetState = true }, + onInfoClick = onPlayPaymentInfoClick, ) } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - isEnabled = true, - ) + Spacer(modifier = Modifier.weight(1f)) - NegativeButton( + NegativeOutlinedButton( text = stringResource(id = R.string.log_out), onClick = onLogoutClick, - isLoading = state.showLogoutLoading, + isLoading = state?.showLogoutLoading == true, + modifier = Modifier.fillMaxWidth(), ) } } @@ -260,7 +247,12 @@ private fun AccountNumberRow(accountNumber: String, onCopyAccountNumber: (String } @Composable -private fun PaidUntilRow(accountExpiry: ZonedDateTime?) { +private fun PaidUntilRow( + accountExpiry: ZonedDateTime?, + verificationPending: Boolean, + onOpenPaymentScreen: () -> Unit, + onInfoClick: () -> Unit, +) { Column(modifier = Modifier.fillMaxWidth()) { Text( style = MaterialTheme.typography.labelMedium, @@ -276,6 +268,29 @@ private fun PaidUntilRow(accountExpiry: ZonedDateTime?) { content = accountExpiry?.toExpiryDateString() ?: "", whenMissing = MissingPolicy.SHOW_SPINNER, ) + Spacer(modifier = Modifier.weight(1f)) + PrimaryTextButton( + onClick = onOpenPaymentScreen, + text = stringResource(R.string.add_time), + textDecoration = TextDecoration.Underline, + ) + } + + if (verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } } } 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 83febe7ea3..6173f1ada9 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 @@ -4,21 +4,30 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +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.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 +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -33,34 +42,27 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination import com.ramcosta.composedestinations.generated.destinations.ConnectDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.SettingsDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination 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.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.button.VariantButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.OutOfTimeScreenPreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.lib.model.ErrorStateCause -import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.lib.payment.model.ProductId 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.ui.tag.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG +import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import org.koin.androidx.compose.koinViewModel @@ -69,36 +71,14 @@ import org.koin.androidx.compose.koinViewModel private fun PreviewOutOfTimeScreen( @PreviewParameter(OutOfTimeScreenPreviewParameterProvider::class) state: OutOfTimeUiState ) { - AppTheme { OutOfTimeScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}, {}, {}) } + AppTheme { OutOfTimeScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}) } } @Destination<RootGraph>(style = HomeTransition::class) @Composable -fun OutOfTime( - navigator: DestinationsNavigator, - redeemVoucherResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun OutOfTime(navigator: DestinationsNavigator) { val vm = koinViewModel<OutOfTimeViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - 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 snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -122,15 +102,12 @@ fun OutOfTime( OutOfTimeScreen( state = state, snackbarHostState = snackbarHostState, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, - onDisconnectClick = vm::onDisconnectClick, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, + onDisconnectClick = vm::onDisconnectClick, ) } @@ -139,14 +116,11 @@ fun OutOfTimeScreen( state: OutOfTimeUiState, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onDisconnectClick: () -> Unit, - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, - onPurchaseBillingProductClick: (ProductId) -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, ) { - val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( snackbarHostState = snackbarHostState, @@ -167,6 +141,15 @@ fun OutOfTimeScreen( deviceName = state.deviceName, timeLeft = null, ) { + var addTimeBottomSheetState by remember { mutableStateOf(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } Column( modifier = Modifier.fillMaxSize() @@ -184,57 +167,55 @@ fun OutOfTimeScreen( ) .background(color = MaterialTheme.colorScheme.surface) ) { - Image( - painter = painterResource(id = R.drawable.icon_fail), - contentDescription = null, - modifier = - Modifier.align(Alignment.CenterHorizontally) - .padding(bottom = Dimens.mediumSpacer), - ) - Text( - text = stringResource(id = R.string.out_of_time), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.testTag(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG), - ) - Text( - text = - buildString { - append(stringResource(R.string.account_credit_has_expired)) - if (state.showSitePayment) { - append(" ") - append(stringResource(R.string.add_time_to_account)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = Dimens.mediumPadding), - ) + Content(showSitePayment = state.showSitePayment) Spacer(modifier = Modifier.weight(1f).defaultMinSize(minHeight = Dimens.verticalSpace)) // Button area - ButtonPanel( state = state, onDisconnectClick = onDisconnectClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onRedeemVoucherClick = onRedeemVoucherClick, - onSitePaymentClick = onSitePaymentClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onAddMoreTimeClick = { addTimeBottomSheetState = true }, + onInfoClick = onPlayPaymentInfoClick, ) } } } @Composable +private fun ColumnScope.Content(showSitePayment: Boolean) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + modifier = + Modifier.align(Alignment.CenterHorizontally).padding(bottom = Dimens.mediumSpacer), + ) + Text( + text = stringResource(id = R.string.out_of_time), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG), + ) + Text( + text = + buildString { + append(stringResource(R.string.account_credit_has_expired)) + if (showSitePayment) { + append(" ") + append(stringResource(R.string.add_time_to_account)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) +} + +@Composable private fun ButtonPanel( state: OutOfTimeUiState, onDisconnectClick: () -> Unit, - onPurchaseBillingProductClick: (ProductId) -> Unit, - onRedeemVoucherClick: () -> Unit, - onSitePaymentClick: () -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onAddMoreTimeClick: () -> Unit, + onInfoClick: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { if (state.tunnelState.isSecured()) { NegativeButton( @@ -242,29 +223,25 @@ private fun ButtonPanel( text = stringResource(id = R.string.disconnect), ) } - state.billingPaymentState?.let { - PlayPayment( - billingPaymentState = state.billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = navigateToVerificationPendingDialog, - ) - } - if (state.showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = state.tunnelState.enableSitePaymentButton(), - ) + if (state.verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onInfoClick, + modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - isEnabled = state.tunnelState.enableRedeemButton(), - ) + VariantButton(onClick = onAddMoreTimeClick, text = stringResource(id = R.string.add_time)) } } - -private fun TunnelState.enableSitePaymentButton(): Boolean = this is TunnelState.Disconnected - -private fun TunnelState.enableRedeemButton(): Boolean = - !(this is TunnelState.Error && this.errorState.cause is ErrorStateCause.IsOffline) 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 482eafe863..3cd23d447d 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 @@ -20,11 +20,15 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -42,92 +46,59 @@ import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination import com.ramcosta.composedestinations.generated.destinations.ConnectDestination import com.ramcosta.composedestinations.generated.destinations.DeviceNameInfoDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.SettingsDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination 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.NegativeButton -import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton -import net.mullvad.mullvadvpn.compose.button.SitePaymentButton +import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton -import net.mullvad.mullvadvpn.compose.component.PlayPayment +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.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.WelcomeScreenUiStatePreviewParameterProvider -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.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces -import net.mullvad.mullvadvpn.lib.payment.model.ProductId 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.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.koin.androidx.compose.koinViewModel -@Preview +@Preview("Loading|Content|TunnelConnected") @Composable private fun PreviewWelcomeScreen( - @PreviewParameter(WelcomeScreenUiStatePreviewParameterProvider::class) state: WelcomeUiState + @PreviewParameter(WelcomeScreenUiStatePreviewParameterProvider::class) + state: Lc<Unit, WelcomeUiState> ) { AppTheme { WelcomeScreen( state = state, - onSitePaymentClick = {}, - onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - onPurchaseBillingProductClick = { _ -> }, navigateToDeviceInfoDialog = {}, - navigateToVerificationPendingDialog = {}, onDisconnectClick = {}, + onRedeemVoucherClick = {}, + onPlayPaymentInfoClick = {}, ) } } @Destination<RootGraph>(style = HomeTransition::class) @Composable -fun Welcome( - navigator: DestinationsNavigator, - voucherRedeemResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun Welcome(navigator: DestinationsNavigator) { val vm = koinViewModel<WelcomeViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - 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 snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() @@ -151,7 +122,7 @@ fun Welcome( val credentialsManager = CredentialManager.create(context) try { credentialsManager.createCredential(context, createPasswordRequest) - } catch (e: CreateCredentialException) { + } catch (_: CreateCredentialException) { Logger.w("Unable to create Credentials") } } @@ -161,32 +132,27 @@ fun Welcome( WelcomeScreen( state = state, snackbarHostState = snackbarHostState, - onSitePaymentClick = dropUnlessResumed { vm.onSitePaymentClick() }, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, navigateToDeviceInfoDialog = dropUnlessResumed { navigator.navigate(DeviceNameInfoDestination) }, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, onDisconnectClick = vm::onDisconnectClick, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, ) } @Composable fun WelcomeScreen( - state: WelcomeUiState, + state: Lc<Unit, WelcomeUiState>, snackbarHostState: SnackbarHostState = SnackbarHostState(), - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, onDisconnectClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, navigateToDeviceInfoDialog: () -> Unit, - navigateToVerificationPendingDialog: () -> Unit, ) { val scrollState = rememberScrollState() @@ -197,6 +163,16 @@ fun WelcomeScreen( onAccountClicked = onAccountClick, snackbarHostState = snackbarHostState, ) { + var addTimeBottomSheetState by remember { mutableStateOf(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } + Column( modifier = Modifier.fillMaxSize() @@ -214,16 +190,15 @@ fun WelcomeScreen( Spacer(modifier = Modifier.weight(1f)) // Button area - ButtonPanel( - showDisconnectButton = state.tunnelState.isSecured(), - showSitePayment = state.showSitePayment, - billingPaymentState = state.billingPaymentState, - onSitePaymentClick = onSitePaymentClick, - onRedeemVoucherClick = onRedeemVoucherClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onPaymentInfoClick = navigateToVerificationPendingDialog, - onDisconnectClick = onDisconnectClick, - ) + if (state is Lc.Content) { + ButtonPanel( + showDisconnectButton = state.value.tunnelState.isSecured(), + verificationPending = state.value.verificationPending, + onAddMoreTimeClick = { addTimeBottomSheetState = true }, + onDisconnectClick = onDisconnectClick, + onInfoClick = onPlayPaymentInfoClick, + ) + } } } } @@ -231,7 +206,7 @@ fun WelcomeScreen( @Composable private fun WelcomeInfo( snackbarHostState: SnackbarHostState, - state: WelcomeUiState, + state: Lc<Unit, WelcomeUiState>, navigateToDeviceInfoDialog: () -> Unit, ) { Column { @@ -258,15 +233,28 @@ private fun WelcomeInfo( color = MaterialTheme.colorScheme.onSurface, ) - AccountNumberRow(snackbarHostState, state) + when (state) { + is Lc.Loading -> + MullvadCircularProgressIndicatorMedium( + modifier = + Modifier.padding( + horizontal = Dimens.sideMargin, + vertical = Dimens.smallPadding, + ) + ) + is Lc.Content -> { + // Account number + AccountNumberRow(snackbarHostState, state.value) - DeviceNameRow(deviceName = state.deviceName, navigateToDeviceInfoDialog) + DeviceNameRow(deviceName = state.value.deviceName, navigateToDeviceInfoDialog) + } + } Text( text = buildString { append(stringResource(id = R.string.pay_to_start_using)) - if (state.showSitePayment) { + if (state.contentOrNull()?.showSitePayment == true) { append(" ") append(stringResource(id = R.string.add_time_to_account)) } @@ -348,65 +336,51 @@ fun DeviceNameRow(deviceName: String?, navigateToDeviceInfoDialog: () -> Unit) { @Composable private fun ButtonPanel( showDisconnectButton: Boolean, - showSitePayment: Boolean, - billingPaymentState: PaymentState?, - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, - onPaymentInfoClick: () -> Unit, + verificationPending: Boolean, + onAddMoreTimeClick: () -> Unit, onDisconnectClick: () -> Unit, + onInfoClick: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().padding(top = Dimens.mediumPadding)) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + top = Dimens.mediumPadding, + start = Dimens.sideMargin, + end = Dimens.sideMargin, + ) + ) { Spacer(modifier = Modifier.padding(top = Dimens.screenTopMargin)) if (showDisconnectButton) { NegativeButton( onClick = onDisconnectClick, text = stringResource(id = R.string.disconnect), - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ), - ) - } - billingPaymentState?.let { - PlayPayment( - billingPaymentState = billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = onPaymentInfoClick, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ) - .align(Alignment.CenterHorizontally), + modifier = Modifier.padding(bottom = Dimens.buttonSpacing), ) } - if (showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ), - ) + if (verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onInfoClick, + modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenBottomMargin, - ), + VariantButton( + onClick = onAddMoreTimeClick, + text = stringResource(id = R.string.add_time), + modifier = Modifier.padding(bottom = Dimens.buttonSpacing), ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt new file mode 100644 index 0000000000..a9b4d3c09c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.payment.model.ProductId + +data class AddTimeUiState( + val purchaseState: PurchaseState?, + val billingPaymentState: PaymentState?, + val showSitePayment: Boolean, + val tunnelStateBlocked: Boolean, +) + +sealed interface PurchaseState { + data object Connecting : PurchaseState + + data object VerificationStarted : PurchaseState + + data object VerifyingPurchase : PurchaseState + + data class Success(val productId: ProductId) : PurchaseState + + sealed interface Error : PurchaseState { + data class TransactionIdError(val productId: ProductId) : Error + + data class OtherError(val productId: ProductId) : Error + } +} 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 6e195d40d8..89708d95eb 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 @@ -6,5 +6,5 @@ data class OutOfTimeUiState( val tunnelState: TunnelState = TunnelState.Disconnected(), val deviceName: String = "", val showSitePayment: Boolean = false, - val billingPaymentState: PaymentState? = null, + val verificationPending: Boolean = false, ) 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 dd9a57626c..880c2b9dcf 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 @@ -4,9 +4,9 @@ import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.TunnelState data class WelcomeUiState( - val tunnelState: TunnelState = TunnelState.Disconnected(), - val accountNumber: AccountNumber? = null, - val deviceName: String? = null, - val showSitePayment: Boolean = false, - val billingPaymentState: PaymentState? = null, + val tunnelState: TunnelState, + val accountNumber: AccountNumber?, + val deviceName: String?, + val showSitePayment: Boolean, + val verificationPending: Boolean, ) 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 cd071746c7..7e798a69c8 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 @@ -63,6 +63,7 @@ import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseC import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel @@ -87,7 +88,6 @@ import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel 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.ResetServerIpOverridesConfirmationViewModel @@ -193,7 +193,7 @@ val uiModule = module { single { AppVersionInfoRepository(get(), get()) } // View models - viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } + viewModel { AccountViewModel(get(), get(), get()) } viewModel { ChangelogViewModel(get(), get(), get()) } viewModel { AppInfoViewModel( @@ -241,7 +241,6 @@ val uiModule = module { viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } - viewModel { PaymentViewModel(get()) } viewModel { FilterViewModel(get(), get()) } viewModel { CreateCustomListDialogViewModel(get(), get()) } viewModel { CustomListLocationsViewModel(get(), get(), get(), get()) } @@ -279,6 +278,14 @@ val uiModule = module { SelectLocationListViewModel(relayListType, get(), get(), get(), get(), get(), get(), get()) } viewModel { DaitaViewModel(get(), get()) } + viewModel { + AddTimeViewModel( + paymentUseCase = get(), + accountRepository = get(), + connectionProxy = get(), + isPlayBuild = IS_PLAY_BUILD, + ) + } // This view model must be single so we correctly attach lifecycle and share it with activity single { MullvadAppViewModel(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt index c1abfb5717..2e18510178 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt @@ -5,10 +5,12 @@ import arrow.core.Either import arrow.core.right import arrow.resilience.Schedule import arrow.resilience.retryEither +import co.touchlab.kermit.Logger import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import net.mullvad.mullvadvpn.constant.VERIFICATION_BACK_OFF_FACTOR import net.mullvad.mullvadvpn.constant.VERIFICATION_INITIAL_BACK_OFF_DURATION @@ -50,12 +52,16 @@ class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : Pay delay(EXTRA_LOADING_DELAY_MS) } } + .onEach { Logger.d("Purchase state: ${it::class.simpleName}") } .collect(_purchaseResult) } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") override suspend fun queryPaymentAvailability() { - paymentRepository.queryPaymentAvailability().collect(_paymentAvailability) + paymentRepository + .queryPaymentAvailability() + .onEach { Logger.d("Payment availability: ${it::class.simpleName}") } + .collect(_paymentAvailability) } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt index aa75c2403a..2b9847dd79 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.util import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PaymentState.PaymentAvailable import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability fun PaymentAvailability.toPaymentState(): PaymentState = @@ -17,3 +18,12 @@ fun PaymentAvailability.toPaymentState(): PaymentState = PaymentAvailability.Error.FeatureNotSupported, PaymentAvailability.Error.ItemUnavailable -> PaymentState.NoPayment } + +fun PaymentAvailability?.hasPendingPayment(): Boolean { + return this?.let { paymentAvailability -> + when (val paymentState = paymentAvailability.toPaymentState()) { + is PaymentAvailable -> paymentState.products.any { it.status != null } + else -> false + } + } == true +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt deleted file mode 100644 index 216a995cce..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt +++ /dev/null @@ -1,78 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogAction -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogIcon -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult - -fun PurchaseResult.toPaymentDialogData(): PaymentDialogData? = - when (this) { - // Idle states - PurchaseResult.Completed.Cancelled, - PurchaseResult.BillingFlowStarted, - is PurchaseResult.Error.BillingError -> { - // Show nothing - null - } - // Fetching products and obfuscated id loading state - PurchaseResult.FetchingProducts, - PurchaseResult.FetchingObfuscationId -> - PaymentDialogData( - title = R.string.connecting, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ) - // Verifying loading states - PurchaseResult.VerificationStarted -> - PaymentDialogData( - title = R.string.loading_verifying, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ) - // Pending state - PurchaseResult.Completed.Pending, - is PurchaseResult.Error.VerificationError -> - PaymentDialogData( - title = R.string.verifying_purchase, - message = R.string.payment_pending_dialog_message, - confirmAction = PaymentDialogAction.Close, - ) - // Success state - PurchaseResult.Completed.Success -> - PaymentDialogData( - title = R.string.payment_completed_dialog_title, - message = R.string.payment_completed_dialog_message, - icon = PaymentDialogIcon.SUCCESS, - confirmAction = PaymentDialogAction.Close, - successfulPayment = true, - ) - // Error states - is PurchaseResult.Error.TransactionIdError -> - PaymentDialogData( - title = R.string.payment_obfuscation_id_error_dialog_title, - message = R.string.payment_obfuscation_id_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = PaymentDialogAction.RetryPurchase(productId = this.productId), - ) - is PurchaseResult.Error.FetchProductsError, - is PurchaseResult.Error.NoProductFound -> { - PaymentDialogData( - title = R.string.payment_billing_error_dialog_title, - message = R.string.payment_billing_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = - PaymentDialogAction.RetryPurchase( - productId = - when (this) { - is PurchaseResult.Error.FetchProductsError -> this.productId - is PurchaseResult.Error.NoProductFound -> this.productId - else -> ProductId("") - } - ), - ) - } - } 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 8dd4253553..08a99a8aef 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 @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import java.time.ZonedDateTime @@ -17,54 +16,49 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState +import net.mullvad.mullvadvpn.util.toLc class AccountViewModel( private val accountRepository: AccountRepository, deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, - private val isPlayBuild: Boolean, ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private val isLoggingOut = MutableStateFlow(false) - private val isLoadingAccountPage = MutableStateFlow(false) - val uiState: StateFlow<AccountUiState> = + val uiState: StateFlow<Lc<Unit, AccountUiState>> = combine( deviceRepository.deviceState.filterIsInstance<DeviceState.LoggedIn>(), accountData(), paymentUseCase.paymentAvailability, isLoggingOut, - isLoadingAccountPage, - ) { deviceState, accountData, paymentAvailability, isLoggingOut, isLoadingAccountPage -> + ) { deviceState, accountData, paymentAvailability, isLoggingOut -> AccountUiState( - deviceName = deviceState.device.displayName(), - accountNumber = deviceState.accountNumber, - accountExpiry = accountData?.expiryDate, - showLogoutLoading = isLoggingOut, - showManageAccountLoading = isLoadingAccountPage, - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), - ) + deviceName = deviceState.device.displayName(), + accountNumber = deviceState.accountNumber, + accountExpiry = accountData?.expiryDate, + showLogoutLoading = isLoggingOut, + verificationPending = paymentAvailability.hasPendingPayment(), + ) + .toLc<Unit, AccountUiState>() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lc.Loading(Unit)) init { updateAccountExpiry() verifyPurchases() - fetchPaymentAvailability() } private fun accountData(): Flow<AccountData?> = @@ -74,17 +68,6 @@ class AccountViewModel( .onStart<AccountData?> { emit(accountRepository.accountData.value) } .distinctUntilChanged() - fun onManageAccountClick() { - if (isLoadingAccountPage.value) return - isLoadingAccountPage.value = true - - viewModelScope.launch { - val wwwAuthToken = accountRepository.getWebsiteAuthToken() - _uiSideEffect.send(UiSideEffect.OpenAccountManagementPageInBrowser(wwwAuthToken)) - isLoadingAccountPage.value = false - } - } - fun onLogoutClick() { if (isLoggingOut.value) return isLoggingOut.value = true @@ -104,8 +87,8 @@ class AccountViewModel( viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } + private fun updateAccountExpiry() { + viewModelScope.launch { accountRepository.getAccountData() } } private fun verifyPurchases() { @@ -116,30 +99,6 @@ class AccountViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - updateAccountExpiry() - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - - private fun updateAccountExpiry() { - viewModelScope.launch { accountRepository.getAccountData() } - } - sealed class UiSideEffect { data object NavigateToLogin : UiSideEffect() @@ -153,24 +112,9 @@ class AccountViewModel( } data class AccountUiState( - val deviceName: String?, - val accountNumber: AccountNumber?, + val deviceName: String, + val accountNumber: AccountNumber, val accountExpiry: ZonedDateTime?, - val showSitePayment: Boolean, - val billingPaymentState: PaymentState? = null, - val showLogoutLoading: Boolean = false, - val showManageAccountLoading: Boolean = false, -) { - companion object { - fun default() = - AccountUiState( - deviceName = null, - accountNumber = null, - accountExpiry = null, - showLogoutLoading = false, - showManageAccountLoading = false, - showSitePayment = false, - billingPaymentState = PaymentState.Loading, - ) - } -} + val showLogoutLoading: Boolean, + val verificationPending: Boolean, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt new file mode 100644 index 0000000000..c865207353 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt @@ -0,0 +1,136 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.isSuccess +import net.mullvad.mullvadvpn.util.toPaymentState +import net.mullvad.mullvadvpn.viewmodel.AddMoreTimeSideEffect.OpenAccountManagementPageInBrowser + +class AddTimeViewModel( + private val paymentUseCase: PaymentUseCase, + private val accountRepository: AccountRepository, + private val connectionProxy: ConnectionProxy, + private val isPlayBuild: Boolean, +) : ViewModel() { + private val _uiSideEffect = Channel<AddMoreTimeSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + val uiState: StateFlow<Lc<Unit, AddTimeUiState>> = + combine( + paymentUseCase.paymentAvailability, + paymentUseCase.purchaseResult, + connectionProxy.tunnelState, + ) { paymentAvailability, purchaseResult, tunnelState -> + Lc.Content( + AddTimeUiState( + purchaseState = purchaseResult?.toPurchaseState(), + billingPaymentState = paymentAvailability?.toPaymentState(), + tunnelStateBlocked = tunnelState.isBlocked(), + showSitePayment = !isPlayBuild, + ) + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = Lc.Loading(Unit), + ) + + init { + verifyPurchases() + fetchPaymentAvailability() + } + + fun onManageAccountClick() { + viewModelScope.launch { + val wwwAuthToken = accountRepository.getWebsiteAuthToken() + _uiSideEffect.send(OpenAccountManagementPageInBrowser(wwwAuthToken)) + } + } + + fun fetchPaymentAvailability() { + viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } + } + + fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { + viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } + } + + fun onClosePurchaseResultDialog(success: Boolean) { + // We are closing the dialog without any action, this can happen either if an error occurred + // during the purchase or the purchase ended successfully. + // If the payment was successful we want to update the account expiry. If not successful we + // should check payment availability and verify any purchases to handle potential errors. + if (success) { + updateAccountExpiry() + } else { + fetchPaymentAvailability() + verifyPurchases() // Attempt to verify again + } + viewModelScope.launch { + paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. + } + } + + private fun verifyPurchases() { + viewModelScope.launch { + if (paymentUseCase.verifyPurchases().isSuccess()) { + updateAccountExpiry() + } + } + } + + private fun updateAccountExpiry() { + viewModelScope.launch { accountRepository.getAccountData() } + } + + private fun PurchaseResult.toPurchaseState() = + when (this) { + // Idle states + PurchaseResult.Completed.Cancelled, + PurchaseResult.BillingFlowStarted, + is PurchaseResult.Error.BillingError -> { + // Show nothing + null + } + // Fetching products and obfuscated id loading state + PurchaseResult.FetchingProducts, + PurchaseResult.FetchingObfuscationId -> PurchaseState.Connecting + // Verifying loading states + PurchaseResult.VerificationStarted -> PurchaseState.VerificationStarted + // Pending state + is PurchaseResult.Completed.Pending, + is PurchaseResult.Error.VerificationError -> PurchaseState.VerifyingPurchase + // Success state + is PurchaseResult.Completed.Success -> PurchaseState.Success(productId) + // Error states + is PurchaseResult.Error.TransactionIdError -> + PurchaseState.Error.TransactionIdError(productId = productId) + is PurchaseResult.Error.FetchProductsError -> + PurchaseState.Error.OtherError(productId = productId) + is PurchaseResult.Error.NoProductFound -> + PurchaseState.Error.OtherError(productId = productId) + } +} + +sealed class AddMoreTimeSideEffect { + data class OpenAccountManagementPageInBrowser(val token: WebsiteAuthToken?) : + AddMoreTimeSideEffect() +} 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 0f8dd8185a..ace068304d 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 @@ -20,8 +20,8 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState class OutOfTimeViewModel( private val accountRepository: AccountRepository, @@ -46,7 +46,7 @@ class OutOfTimeViewModel( tunnelState = tunnelState, deviceName = deviceState?.displayName() ?: "", showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), + verificationPending = paymentAvailability.hasPendingPayment(), ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState()) @@ -59,7 +59,6 @@ class OutOfTimeViewModel( } } verifyPurchases() - fetchPaymentAvailability() viewModelScope.launch { deviceRepository.updateDevice() } } @@ -84,26 +83,6 @@ class OutOfTimeViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - viewModelScope.launch { updateAccountExpiry() } - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - private suspend fun updateAccountExpiry() { accountRepository.getAccountData() } 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 deleted file mode 100644 index 5deae9534d..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -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/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 7e41c05286..dcda3ee917 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 @@ -24,8 +24,9 @@ import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState class WelcomeViewModel( private val accountRepository: AccountRepository, @@ -44,15 +45,17 @@ class WelcomeViewModel( deviceRepository.deviceState.filterNotNull(), paymentUseCase.paymentAvailability, ) { tunnelState, accountState, paymentAvailability -> - WelcomeUiState( - tunnelState = tunnelState, - accountNumber = accountState.accountNumber(), - deviceName = accountState.displayName(), - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), + Lc.Content( + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = accountState.accountNumber(), + deviceName = accountState.displayName(), + showSitePayment = !isPlayBuild, + verificationPending = paymentAvailability.hasPendingPayment(), + ) ) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lc.Loading(Unit)) init { viewModelScope.launch { @@ -62,10 +65,10 @@ class WelcomeViewModel( } } verifyPurchases() - fetchPaymentAvailability() viewModelScope.launch { deviceRepository.updateDevice() } viewModelScope.launch { - val accountNumber = uiState.map { it.accountNumber }.filterNotNull().first() + val accountNumber = + uiState.map { it.contentOrNull()?.accountNumber }.filterNotNull().first() _uiSideEffect.send(UiSideEffect.StoreCredentialsRequest(accountNumber)) } } @@ -98,27 +101,6 @@ class WelcomeViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - viewModelScope.launch { updateAccountExpiry() } - // Emission of out of time navigation is handled by launch in onStart - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - private suspend fun updateAccountExpiry() { accountRepository.getAccountData() } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt index a07fefabe3..c855b9a473 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt @@ -78,7 +78,7 @@ class PlayPaymentUseCaseTest { @Test fun `resetPurchaseResult call should result in purchaseResult null`() = runTest { // Arrange - val completedSuccess = PurchaseResult.Completed.Success + val completedSuccess = PurchaseResult.Completed.Success(ProductId("one_month")) val productId = ProductId("productId") val paymentRepositoryPurchaseResultFlow = flow { emit(completedSuccess) } every { mockPaymentRepository.purchaseProduct(any(), any()) } returns 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 74a6bbd414..7a6b756bf5 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 @@ -1,34 +1,32 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import app.cash.turbine.test import arrow.core.right 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 java.time.ZonedDateTime import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.data.UUID import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability 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.PurchaseResult +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -54,17 +52,14 @@ class AccountViewModelTest { DeviceState.LoggedIn(accountNumber = dummyAccountNumber, device = dummyDevice) ) private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null) - private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) private val accountExpiryState = MutableStateFlow(null) private lateinit var viewModel: AccountViewModel @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) every { mockAccountRepository.accountData } returns accountExpiryState every { mockDeviceRepository.deviceState } returns deviceState - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability coEvery { mockAccountRepository.getAccountData() } returns null @@ -73,7 +68,6 @@ class AccountViewModelTest { accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, paymentUseCase = mockPaymentUseCase, - isPlayBuild = false, ) } @@ -89,7 +83,8 @@ class AccountViewModelTest { deviceState.value = DeviceState.LoggedIn(accountNumber = dummyAccountNumber, device = dummyDevice) val result = awaitItem() - assertEquals(dummyAccountNumber, result.accountNumber) + assertIs<Lc.Content<AccountUiState>>(result) + assertEquals(dummyAccountNumber, result.value.accountNumber) } } @@ -106,119 +101,29 @@ class AccountViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should be NoPayment`() = - runTest { - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.ProductsUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } + fun `when there is a pending purchase, uiState should reflect it`() = runTest { + // Arrange + paymentAvailability.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) - @Test - fun `when paymentAvailability emits ErrorOther uiState should be ErrorGeneric`() = runTest { // Act, Assert viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.Error.Other(mockk())) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = - runTest { - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.Error.BillingUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit( - PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - ) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } + val result = awaitItem() + assertIs<Lc.Content<AccountUiState>>(result) + assertEquals(true, result.value.verificationPending) } - - @Test - fun `startBillingPayment should invoke purchaseProduct on PaymentUseCase`() { - // Arrange - val mockProductId = ProductId("MOCK") - val mockActivityProvider = mockk<() -> Activity>() - - // Act - viewModel.startBillingPayment(mockProductId, mockActivityProvider) - - // Assert - coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke fetchAccountExpiry on AccountRepository`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockAccountRepository.getAccountData() } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.queryPaymentAvailability() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } } companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" private const val DUMMY_DEVICE_NAME = "fake_name" } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt new file mode 100644 index 0000000000..3d4eb07905 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt @@ -0,0 +1,200 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Activity +import app.cash.turbine.test +import arrow.core.right +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.TunnelState +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.lib.payment.model.VerificationResult +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AddTimeViewModelTest { + + private val mockPaymentUseCase: PaymentUseCase = mockk() + private val mockAccountRepository: AccountRepository = mockk() + private val mockConnectionProxy: ConnectionProxy = mockk() + + private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null) + private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) + private val tunnelState = MutableStateFlow(TunnelState.Disconnected(null)) + + private lateinit var viewModel: AddTimeViewModel + + @BeforeEach + fun setUp() { + every { mockPaymentUseCase.paymentAvailability } returns paymentAvailability + every { mockPaymentUseCase.purchaseResult } returns purchaseResult + every { mockConnectionProxy.tunnelState } returns tunnelState + + coEvery { mockPaymentUseCase.verifyPurchases() } returns + VerificationResult.NothingToVerify.right() + coEvery { mockPaymentUseCase.queryPaymentAvailability() } just Runs + coEvery { mockPaymentUseCase.resetPurchaseResult() } just Runs + coEvery { mockAccountRepository.getAccountData() } returns null + + viewModel = + AddTimeViewModel( + paymentUseCase = mockPaymentUseCase, + accountRepository = mockAccountRepository, + connectionProxy = mockConnectionProxy, + isPlayBuild = true, + ) + } + + @Test + fun `when paymentAvailability emits ProductsUnavailable uiState should be NoPayment`() = + runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.ProductsUnavailable) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.NoPayment>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ErrorOther uiState should be null`() = runTest { + // Arrange + paymentAvailability.tryEmit(PaymentAvailability.Error.Other(mockk())) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.Error.Generic>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = + runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.Error.BillingUnavailable) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.Error.Billing>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = + runTest { + // Arrange + val mockProduct: PaymentProduct = mockk() + val expectedProductList = listOf(mockProduct) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit( + PaymentAvailability.ProductsAvailable(listOf(mockProduct)) + ) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.PaymentAvailable>(result.value.billingPaymentState) + assertLists(expectedProductList, result.value.billingPaymentState.products) + } + } + + @Test + fun `startBillingPayment should invoke purchaseProduct on PaymentUseCase`() { + // Arrange + val mockProductId = ProductId("MOCK") + val mockActivityProvider = mockk<() -> Activity>() + coEvery { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } just + Runs + + // Act + viewModel.startBillingPayment(mockProductId, mockActivityProvider) + + // Assert + coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } + } + + @Test + fun `onClosePurchaseResultDialog with success should invoke fetchAccountExpiry on AccountRepository`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = true) + + // Assert + coVerify { mockAccountRepository.getAccountData() } + } + + @Test + fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = true) + + // Assert + coVerify { mockPaymentUseCase.resetPurchaseResult() } + } + + @Test + fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = false) + + // Assert + coVerify { mockPaymentUseCase.queryPaymentAvailability() } + } + + @Test + fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = false) + + // Assert + coVerify { mockPaymentUseCase.resetPurchaseResult() } + } + + @Test + fun `purchaseResult emitting Success should result in success dialog state`() = runTest { + // Arrange + val result = PurchaseState.Success(ProductId("one_month")) + val purchaseResultData = PurchaseResult.Completed.Success(ProductId("one_month")) + + // Act, Assert + viewModel.uiState.test { + awaitItem() + purchaseResult.value = purchaseResultData + val item = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(item) + assertEquals(result, item.value.purchaseState) + } + } +} 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 bfaed10629..fab79f19c4 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 @@ -7,22 +7,23 @@ 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 kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability 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.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy @@ -63,8 +64,6 @@ class OutOfTimeViewModelTest { @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow every { mockConnectionProxy.tunnelState } returns tunnelState @@ -151,108 +150,25 @@ class OutOfTimeViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should include state NoPayment`() = - runTest { - // Arrange - val productsUnavailable = PaymentAvailability.ProductsUnavailable - paymentAvailabilityFlow.value = productsUnavailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorOther uiState should include state ErrorGeneric`() = - runTest { - // Arrange - val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk()) - paymentAvailabilityFlow.value = paymentAvailabilityError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = - runTest { - // Arrange - val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable - paymentAvailabilityFlow.value = paymentAvailabilityError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailabilityFlow.value = productsAvailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke getAccountData on AccountRepository`() { - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockAccountRepository.getAccountData() } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.queryPaymentAvailability() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { + fun `when there is a pending purchase, uiState should reflect it`() = runTest { // Arrange + paymentAvailabilityFlow.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertIs<OutOfTimeUiState>(result) + assertEquals(true, result.verificationPending) + } } } 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 deleted file mode 100644 index 49e98c95e6..0000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -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.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith - -@ExtendWith(TestCoroutineRule::class) -class PaymentViewModelTest { - - private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) - - private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) - - private lateinit var viewModel: PaymentViewModel - - @BeforeEach - fun setup() { - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult - - viewModel = PaymentViewModel(paymentUseCase = mockPaymentUseCase) - } - - @AfterEach - fun tearDown() { - unmockkAll() - } - - @Test - fun `given PaymentUseCase purchaseResult emits cancelled uiSideEffect should emit PaymentCancelled`() = - 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 `purchaseResult emitting Success should result in success dialog state`() = 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/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt index b2b59ba69e..a96d59361a 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 @@ -7,7 +7,6 @@ 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 java.time.ZonedDateTime import kotlin.test.assertEquals @@ -15,9 +14,8 @@ import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -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.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device @@ -26,6 +24,9 @@ import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability 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.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy @@ -33,6 +34,7 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -63,8 +65,6 @@ class WelcomeViewModelTest { @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockDeviceRepository.deviceState } returns deviceStateFlow every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow @@ -120,7 +120,8 @@ class WelcomeViewModelTest { awaitItem() tunnelState.emit(tunnelUiStateTestItem) val result = awaitItem() - assertEquals(tunnelUiStateTestItem, result.tunnelState) + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(tunnelUiStateTestItem, result.value.tunnelState) } } @@ -139,7 +140,9 @@ class WelcomeViewModelTest { paymentAvailabilityFlow.value = null deviceStateFlow.value = DeviceState.LoggedIn(accountNumber = expectedAccountNumber, device = device) - assertEquals(expectedAccountNumber, awaitItem().accountNumber) + val result = awaitItem() + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(expectedAccountNumber, result.value.accountNumber) } } @@ -158,66 +161,6 @@ class WelcomeViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should include state NoPayment`() = - runTest { - // Arrange - val productsUnavailable = PaymentAvailability.ProductsUnavailable - - // Act, Assert - viewModel.uiState.test { - // Default item - awaitItem() - paymentAvailabilityFlow.tryEmit(productsUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorOther uiState should include state ErrorGeneric`() = - runTest { - // Arrange - val paymentOtherError = PaymentAvailability.Error.Other(mockk()) - paymentAvailabilityFlow.tryEmit(paymentOtherError) - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should include state ErrorBilling`() = - runTest { // Arrange - val paymentBillingError = PaymentAvailability.Error.BillingUnavailable - paymentAvailabilityFlow.value = paymentBillingError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should include state Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailabilityFlow.value = productsAvailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } - } - - @Test fun `when on disconnect click is called should call connection proxy disconnect`() = runTest { // Arrange coEvery { mockConnectionProxy.disconnect() } returns true.right() @@ -229,8 +172,26 @@ class WelcomeViewModelTest { coVerify { mockConnectionProxy.disconnect() } } - companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" + @Test + fun `when there is a pending purchase, uiState should reflect it`() = runTest { + // Arrange + paymentAvailabilityFlow.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) + + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(true, result.value.verificationPending) + } } } diff --git a/android/config/lint-baseline.xml b/android/config/lint-baseline.xml index a04025a47a..c28d26083b 100644 --- a/android/config/lint-baseline.xml +++ b/android/config/lint-baseline.xml @@ -84,4 +84,15 @@ column="9"/> </issue> + <issue + id="VectorPath" + message="Very long vector path (1169 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector." + errorLine1=" <path android:fillColor="#5F6368" android:pathData="M1129.1,463.2V741h-88.2V54.8h233.8c56.4,-1.2 110.9,20.2 151.4,59.4c41,36.9 64.1,89.7 63.2,144.8c1.2,55.5 -21.9,108.7 -63.2,145.7c-40.9,39 -91.4,58.5 -151.4,58.4L1129.1,463.2L1129.1,463.2zM1129.1,139.3v239.6h147.8c32.8,1 64.4,-11.9 87.2,-35.5c46.3,-45 47.4,-119.1 2.3,-165.4c-0.8,-0.8 -1.5,-1.6 -2.3,-2.3c-22.5,-24.1 -54.3,-37.3 -87.2,-36.4L1129.1,139.3L1129.1,139.3zM1692.5,256.2c65.2,0 116.6,17.4 154.3,52.2c37.7,34.8 56.5,82.6 56.5,143.2V741H1819v-65.2h-3.8c-36.5,53.7 -85.1,80.5 -145.7,80.5c-51.7,0 -95,-15.3 -129.8,-46c-33.8,-28.5 -53,-70.7 -52.2,-115c0,-48.6 18.4,-87.2 55.1,-115.9c36.7,-28.7 85.7,-43.1 147.1,-43.1c52.3,0 95.5,9.6 129.3,28.7v-20.2c0.2,-30.2 -13.2,-58.8 -36.4,-78c-23.3,-21 -53.7,-32.5 -85.1,-32.1c-49.2,0 -88.2,20.8 -116.9,62.3l-77.6,-48.9C1545.6,286.8 1608.8,256.2 1692.5,256.2L1692.5,256.2zM1578.4,597.3c-0.1,22.8 10.8,44.2 29.2,57.5c19.5,15.3 43.7,23.5 68.5,23c37.2,-0.1 72.9,-14.9 99.2,-41.2c29.2,-27.5 43.8,-59.7 43.8,-96.8c-27.5,-21.9 -65.8,-32.9 -115,-32.9c-35.8,0 -65.7,8.6 -89.6,25.9C1590.4,550.4 1578.4,571.7 1578.4,597.3L1578.4,597.3zM2387.3,271.5L2093,948h-91l109.2,-236.7l-193.6,-439.8h95.8l139.9,337.3h1.9l136.1,-337.3L2387.3,271.5z"/>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="src/main/res/drawable/google_pay_primary_logo_logo_svgrepo_com.xml" + line="3" + column="57"/> + </issue> + </issues> diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt index 39cc584a57..078cd3b838 100644 --- a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt @@ -47,7 +47,7 @@ class BillingPaymentRepository( .associate { it.products.first() to it.purchaseState.toPaymentStatus() } emit( billingRepository - .queryProducts(listOf(ProductIds.OneMonth)) + .queryProducts(listOf(ProductIds.OneMonth, ProductIds.ThreeMonths)) .toPaymentAvailability(productIdToPaymentStatus) ) } @@ -121,14 +121,14 @@ class BillingPaymentRepository( return@flow } if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { - emit(PurchaseResult.Completed.Pending) + emit(PurchaseResult.Completed.Pending(ProductId(purchase.products.first()))) } else { emit(PurchaseResult.VerificationStarted) emit( verifyPurchase(event.purchases.first()) .fold( { PurchaseResult.Error.VerificationError(null) }, - { PurchaseResult.Completed.Success }, + { productId -> PurchaseResult.Completed.Success(productId) }, ) ) } @@ -164,10 +164,12 @@ class BillingPaymentRepository( } private suspend fun verifyPurchase(purchase: Purchase) = - playPurchaseRepository.verifyPlayPurchase( - PlayPurchase( - productId = purchase.products.first(), - purchaseToken = PlayPurchasePaymentToken(purchase.purchaseToken), + playPurchaseRepository + .verifyPlayPurchase( + PlayPurchase( + productId = purchase.products.first(), + purchaseToken = PlayPurchasePaymentToken(purchase.purchaseToken), + ) ) - ) + .map { ProductId(purchase.products.first()) } } diff --git a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt index d04c40029e..97815d41ee 100644 --- a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt +++ b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt @@ -385,6 +385,7 @@ class BillingPaymentRepositoryTest { val mockBillingPurchase: Purchase = mockk() val mockBillingResult: BillingResult = mockk() every { mockBillingPurchase.purchaseState } returns Purchase.PurchaseState.PENDING + every { mockBillingPurchase.products } returns listOf("MOCK") every { mockBillingResult.responseCode } returns BillingResponseCode.OK coEvery { mockBillingRepository.startPurchaseFlow( diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt index 1849c5abf9..61d8ec89e3 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt @@ -39,11 +39,13 @@ sealed class TunnelState { } } - fun isUsingDaita(): Boolean { + fun isBlocked(): Boolean { return when (this) { - is Connected -> endpoint.daita - is Connecting -> endpoint?.daita ?: false - else -> false + is Connected, + is Disconnected -> false + is Connecting, + is Disconnecting -> true + is Error -> this.errorState.isBlocking } } } diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt index 8754968891..7ff6cc2921 100644 --- a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt @@ -2,4 +2,5 @@ package net.mullvad.mullvadvpn.lib.payment object ProductIds { const val OneMonth = "one_month" + const val ThreeMonths = "three_months" } diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt index f5b89bffe6..203dc8c61e 100644 --- a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt @@ -10,12 +10,12 @@ sealed interface PurchaseResult { data object VerificationStarted : PurchaseResult sealed interface Completed : PurchaseResult { - data object Success : Completed + data class Success(val productId: ProductId) : Completed data object Cancelled : Completed // This ends our part of the purchase flow. The rest is handled by Google and the api. - data object Pending : Completed + data class Pending(val productId: ProductId) : Completed } sealed interface Error : PurchaseResult { diff --git a/android/lib/resource/src/main/res/drawable/google_pay_primary_logo_logo_svgrepo_com.xml b/android/lib/resource/src/main/res/drawable/google_pay_primary_logo_logo_svgrepo_com.xml new file mode 100644 index 0000000000..eff207b6d4 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/google_pay_primary_logo_logo_svgrepo_com.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="79dp" android:viewportHeight="948" android:viewportWidth="2387.3" android:width="200dp"> + + <path android:fillColor="#5F6368" android:pathData="M1129.1,463.2V741h-88.2V54.8h233.8c56.4,-1.2 110.9,20.2 151.4,59.4c41,36.9 64.1,89.7 63.2,144.8c1.2,55.5 -21.9,108.7 -63.2,145.7c-40.9,39 -91.4,58.5 -151.4,58.4L1129.1,463.2L1129.1,463.2zM1129.1,139.3v239.6h147.8c32.8,1 64.4,-11.9 87.2,-35.5c46.3,-45 47.4,-119.1 2.3,-165.4c-0.8,-0.8 -1.5,-1.6 -2.3,-2.3c-22.5,-24.1 -54.3,-37.3 -87.2,-36.4L1129.1,139.3L1129.1,139.3zM1692.5,256.2c65.2,0 116.6,17.4 154.3,52.2c37.7,34.8 56.5,82.6 56.5,143.2V741H1819v-65.2h-3.8c-36.5,53.7 -85.1,80.5 -145.7,80.5c-51.7,0 -95,-15.3 -129.8,-46c-33.8,-28.5 -53,-70.7 -52.2,-115c0,-48.6 18.4,-87.2 55.1,-115.9c36.7,-28.7 85.7,-43.1 147.1,-43.1c52.3,0 95.5,9.6 129.3,28.7v-20.2c0.2,-30.2 -13.2,-58.8 -36.4,-78c-23.3,-21 -53.7,-32.5 -85.1,-32.1c-49.2,0 -88.2,20.8 -116.9,62.3l-77.6,-48.9C1545.6,286.8 1608.8,256.2 1692.5,256.2L1692.5,256.2zM1578.4,597.3c-0.1,22.8 10.8,44.2 29.2,57.5c19.5,15.3 43.7,23.5 68.5,23c37.2,-0.1 72.9,-14.9 99.2,-41.2c29.2,-27.5 43.8,-59.7 43.8,-96.8c-27.5,-21.9 -65.8,-32.9 -115,-32.9c-35.8,0 -65.7,8.6 -89.6,25.9C1590.4,550.4 1578.4,571.7 1578.4,597.3L1578.4,597.3zM2387.3,271.5L2093,948h-91l109.2,-236.7l-193.6,-439.8h95.8l139.9,337.3h1.9l136.1,-337.3L2387.3,271.5z"/> + + <path android:fillColor="#4285F4" android:pathData="M772.8,403.2c0,-26.9 -2.2,-53.7 -6.8,-80.2H394.2v151.8h212.9c-8.8,49 -37.2,92.3 -78.7,119.8v98.6h127.1C729.9,624.7 772.8,523.2 772.8,403.2L772.8,403.2z"/> + + <path android:fillColor="#34A853" android:pathData="M394.2,788.5c106.4,0 196,-34.9 261.3,-95.2l-127.1,-98.6c-35.4,24 -80.9,37.7 -134.2,37.7c-102.8,0 -190.1,-69.3 -221.3,-162.7H42v101.6C108.9,704.5 245.2,788.5 394.2,788.5z"/> + + <path android:fillColor="#FBBC04" android:pathData="M172.9,469.7c-16.5,-48.9 -16.5,-102 0,-150.9V217.2H42c-56,111.4 -56,242.7 0,354.1L172.9,469.7z"/> + + <path android:fillColor="#EA4335" android:pathData="M394.2,156.1c56.2,-0.9 110.5,20.3 151.2,59.1L658,102.7C586.6,35.7 492.1,-1.1 394.2,0C245.2,0 108.9,84.1 42,217.2l130.9,101.6C204.1,225.4 291.4,156.1 394.2,156.1z"/> + +</vector> diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index ad2d271799..0bbfee2878 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Viser påmindelser, når kontotiden er ved at udløbe</string> <string name="account_time_notification_channel_name">Påmindelser om kontotid</string> <string name="add">Tilføj</string> - <string name="add_30_days_time">Tilføj 30 dages tid</string> <string name="add_30_days_time_x">Tilføj 30 dages tid (%1$s)</string> <string name="add_a_server">Tilføj en server</string> <string name="add_dns_server_dialog_title">Tilføj DNS-server</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Ikke alle vores servere er %1$s-kompatible. Derfor bruger vi automatisk multihop til at aktivere %1$s med enhver server.</string> <string name="daita_info">Hvis du aktiverer \"%1$s\", skal du manuelt vælge en server, der er %2$s-aktiveret. Det kan medføre, at du ender i en blokeret tilstand, indtil du har valgt en kompatibel server i visningen \"Vælg placering\".</string> <string name="daita_multihop">%1$s: Multihop</string> + <string name="days_were_added_30">30 dage blev føjet til din konto.</string> <string name="delete">Slet</string> <string name="delete_custom_list_confirmation_description">Vil du slette \"%1$s\"?</string> <string name="delete_custom_list_message">\"%1$s\" blev slettet</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Login mislykkedes</string> <string name="login_title">Log ind</string> <string name="malware_info">Advarsel: Malware-blokkeren er ikke antivirus og bør ikke behandles som sådan. Dette er blot et ekstra lag af beskyttelse.</string> - <string name="manage_account">Administrer konto</string> <string name="manage_devices">Administrer enheder</string> <string name="manage_devices_confirm_removal_description_line1">Fjern %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Enheden fjernes fra listen og logges ud.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">For at begynde at bruge appen skal du først føje tid til din konto.</string> <string name="payment_billing_error_dialog_message">Vi kunne ikke starte betalingsprocessen. Sørg for, at du har den nyeste version af Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play er ikke tilgængelig</string> - <string name="payment_completed_dialog_message">30 dage blev føjet til din konto.</string> - <string name="payment_completed_dialog_title">Tid blev tilføjet</string> <string name="payment_obfuscation_id_error_dialog_message">Vi kunne ikke starte betalingsprocessen. Prøv igen senere.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-tjenester er ikke tilgængelige</string> - <string name="payment_pending_dialog_message">Vi er i øjeblikket ved at bekræfte dit køb, det kan tage noget tid. Din tid vil blive tilføjet, hvis bekræftelsen lykkes.</string> - <string name="payment_status_pending">Google Play-betaling afventer</string> + <string name="payment_status_pending_short">Google Play-betaling afventer</string> <string name="please_enter_a_valid_ip_address">Indtast en gyldig IPv4- eller IPv6-adresse</string> <string name="please_enter_a_valid_remote_server_port">Indtast en gyldig fjernserverport</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index 5435e48109..73573dfb00 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Erinnerungen anzeigen, wenn die Kontozeit bald abläuft</string> <string name="account_time_notification_channel_name">Erinnerungen an die Kontozeit</string> <string name="add">Hinzufügen</string> - <string name="add_30_days_time">30 Tage Zeit hinzufügen</string> <string name="add_30_days_time_x">30 Tage Zeit hinzufügen (%1$s)</string> <string name="add_a_server">Server hinzufügen</string> <string name="add_dns_server_dialog_title">DNS-Server hinzufügen</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Nicht alle unsere Server sind %1$s-fähig. Daher verwenden wir automatisch Multihop, um %1$s mit jedem Server zu aktivieren.</string> <string name="daita_info">Wenn Sie „%1$s“ aktivieren, müssen Sie manuell einen Server auswählen, der %2$s-fähig ist. Dies kann dazu führen, dass Sie in einem blockierten Zustand landen, bis Sie einen kompatiblen Server in der Ansicht „Standort auswählen“ ausgewählt haben.</string> <string name="daita_multihop">%1$s: Multihop</string> + <string name="days_were_added_30">30 Tage wurden zu Ihrem Konto hinzugefügt.</string> <string name="delete">Löschen</string> <string name="delete_custom_list_confirmation_description">„%1$s“ löschen?</string> <string name="delete_custom_list_message">„%1$s“ wurde gelöscht</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Anmeldung fehlgeschlagen</string> <string name="login_title">Anmelden</string> <string name="malware_info">Der Malware-Blocker ist kein Antivirusprogramm und sollte auch nicht als solches behandelt werden. Es dient lediglich als zusätzliche Schutzschicht.</string> - <string name="manage_account">Konto verwalten</string> <string name="manage_devices">Geräte verwalten</string> <string name="manage_devices_confirm_removal_description_line1">%1$s entfernen?</string> <string name="manage_devices_confirm_removal_description_line2">Das Gerät wird aus der Liste entfernt und abgemeldet.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Um mit der Nutzung dieser App zu beginnen, müssen Sie erst einmal Zeit zu Ihrem Konto hinzufügen.</string> <string name="payment_billing_error_dialog_message">Wir konnten den Zahlungsvorgang nicht starten. Bitte vergewissern Sie sich, dass Sie die neueste Version von Google Play haben.</string> <string name="payment_billing_error_dialog_title">Google Play nicht verfügbar</string> - <string name="payment_completed_dialog_message">30 Tage wurden zu Ihrem Konto hinzugefügt.</string> - <string name="payment_completed_dialog_title">Zeit erfolgreich hinzugefügt</string> <string name="payment_obfuscation_id_error_dialog_message">Wir konnten den Zahlungsvorgang nicht starten, bitte versuchen Sie es später noch einmal.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-Dienste nicht verfügbar</string> - <string name="payment_pending_dialog_message">Wir verifizieren gerade Ihren Kauf, dies kann einige Zeit dauern. Ihre Zeit wird hinzugefügt, wenn die Verifizierung erfolgreich ist.</string> - <string name="payment_status_pending">Google-Play-Zahlung ausstehend</string> + <string name="payment_status_pending_short">Google-Play-Zahlung ausstehend</string> <string name="please_enter_a_valid_ip_address">Bitte geben Sie eine gültige IPv4- oder IPv6-Adresse ein</string> <string name="please_enter_a_valid_remote_server_port">Bitte geben Sie einen gültigen Remote-Server-Port ein</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index ac81c247ff..31a1720096 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Muestra avisos cuando el tiempo de la cuenta está a punto de caducar</string> <string name="account_time_notification_channel_name">Recordatorios de tiempo de la cuenta</string> <string name="add">Añadir</string> - <string name="add_30_days_time">Añadir 30 días</string> <string name="add_30_days_time_x">Añadir 30 días (%1$s)</string> <string name="add_a_server">Añadir un servidor</string> <string name="add_dns_server_dialog_title">Añadir servidor DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">No todos nuestros servidores están habilitados para %1$s. Por lo tanto, utilizamos el salto múltiple de forma automática para habilitar %1$s con cualquier servidor.</string> <string name="daita_info">Si habilita «%1$s», deberá seleccionar manualmente un servidor que esté habilitado para %2$s. Esto puede provocar que termine bloqueado hasta que seleccione un servidor compatible en la vista «Seleccionar ubicación».</string> <string name="daita_multihop">%1$s: Salto múltiple</string> + <string name="days_were_added_30">Se han añadido 30 días a su cuenta.</string> <string name="delete">Eliminar</string> <string name="delete_custom_list_confirmation_description">¿Eliminar «%1$s»?</string> <string name="delete_custom_list_message">Se ha eliminado «%1$s»</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Error de inicio de sesión</string> <string name="login_title">Iniciar sesión</string> <string name="malware_info">Advertencia: El bloqueador de malware no es un antivirus y no debe considerarse como tal, tan solo es un nivel de protección adicional.</string> - <string name="manage_account">Administrar cuenta</string> <string name="manage_devices">Gestionar dispositivos</string> <string name="manage_devices_confirm_removal_description_line1">¿Quitar %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">El dispositivo se quitará de la lista y se cerrará la sesión en él.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Para empezar a usar la aplicación, primero necesita agregar tiempo a su cuenta.</string> <string name="payment_billing_error_dialog_message">No hemos podido iniciar el proceso de pago. Asegúrese de tener la última versión de Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play no disponible</string> - <string name="payment_completed_dialog_message">Se han añadido 30 días a su cuenta.</string> - <string name="payment_completed_dialog_title">Se añadió correctamente el tiempo</string> <string name="payment_obfuscation_id_error_dialog_message">No hemos podido iniciar el proceso de pago. Inténtelo de nuevo más tarde.</string> <string name="payment_obfuscation_id_error_dialog_title">Servicios de Mullvad no disponibles</string> - <string name="payment_pending_dialog_message">Estamos verificando su compra en este momento. Esto podría tardar algún tiempo. Su tiempo se añadirá si pasa la verificación.</string> - <string name="payment_status_pending">Pago a Google Play pendiente</string> + <string name="payment_status_pending_short">Pago a Google Play pendiente</string> <string name="please_enter_a_valid_ip_address">Escriba una dirección IPv4 o IPv6 válida</string> <string name="please_enter_a_valid_remote_server_port">Introduzca un puerto de servidor remoto válido</string> <string name="port">Puerto</string> diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index 9df735f87b..310aaf6040 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Näyttää muistutuksia, kun tilin käyttöaika on umpeutumassa</string> <string name="account_time_notification_channel_name">Muistutukset tilin käyttöajasta</string> <string name="add">Lisää</string> - <string name="add_30_days_time">Lisää 30 päivää käyttöaikaa</string> <string name="add_30_days_time_x">Lisää 30 päivää käyttöaikaa (%1$s)</string> <string name="add_a_server">Lisää palvelin</string> <string name="add_dns_server_dialog_title">Lisää DNS-palvelin</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Kaikissa palvelimissamme ei ole %1$s-tukea. Siksi käytämme multihopia automaattisesti mahdollistaaksemme %1$s:n millä tahansa palvelimella.</string> <string name="daita_info">Kun \"%1$s\" otetaan käyttöön, sinun on valittava manuaalisesti palvelin, jossa on %2$s-tuki. Tämä voi aiheuttaa sen, että päädyt estettyyn tilaan, kunnes olet valinnut yhteensopivan palvelimen \"Valitse sijainti\" -näkymästä.</string> <string name="daita_multihop">%1$s: multihop</string> + <string name="days_were_added_30">Tilillesi lisättiin 30 päivää käyttöaikaa.</string> <string name="delete">Poista</string> <string name="delete_custom_list_confirmation_description">Poistetaanko \"%1$s\"?</string> <string name="delete_custom_list_message">\"%1$s\" poistettiin</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Sisäänkirjautuminen epäonnistui</string> <string name="login_title">Kirjaudu sisään</string> <string name="malware_info">Varoitus: haittaohjelmien estotoiminto ei ole virustorjuntaohjelma, eikä sitä pidä käyttää sellaisena – kyseessä on vain ylimääräinen suojauskerros.</string> - <string name="manage_account">Tilin hallinta</string> <string name="manage_devices">Hallitse laitteita</string> <string name="manage_devices_confirm_removal_description_line1">Poistetaanko %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Laite poistetaan luettelosta ja kirjataan ulos.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Voit aloittaa sovelluksen käyttämisen lisäämällä ensin aikaa tilillesi.</string> <string name="payment_billing_error_dialog_message">Emme pystyneet aloittamaan maksun käsittelyä. Varmista, että käytät Google Playn uusinta versiota.</string> <string name="payment_billing_error_dialog_title">Google Play ei ole käytettävissä</string> - <string name="payment_completed_dialog_message">Tilillesi lisättiin 30 päivää käyttöaikaa.</string> - <string name="payment_completed_dialog_title">Aika lisättiin onnistuneesti</string> <string name="payment_obfuscation_id_error_dialog_message">Emme pystyneet aloittamaan maksun käsittelyä. Yritä myöhemmin uudelleen.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-palvelut eivät ole käytettävissä</string> - <string name="payment_pending_dialog_message">Vahvistamme ostostasi parhaillaan. Siinä saattaa vierähtää jonkin aikaa. Tilillesi lisätään käyttöaikaa, kunhan ostoksen vahvistus onnistuu.</string> - <string name="payment_status_pending">Google Play -maksu odottaa</string> + <string name="payment_status_pending_short">Google Play -maksu odottaa</string> <string name="please_enter_a_valid_ip_address">Anna kelvollinen IPv4- tai IPv6-osoite</string> <string name="please_enter_a_valid_remote_server_port">Anna kelvollinen etäpalvelimen portti</string> <string name="port">Portti</string> diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 31172da91d..710db2f8a8 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Affiche des rappels lorsque le temps du compte va expirer</string> <string name="account_time_notification_channel_name">Rappels de temps pour le compte</string> <string name="add">Ajouter</string> - <string name="add_30_days_time">Ajouter 30 jours de temps</string> <string name="add_30_days_time_x">Ajouter 30 jours de temps (%1$s)</string> <string name="add_a_server">Ajouter un serveur</string> <string name="add_dns_server_dialog_title">Ajouter un serveur DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Tous nos serveurs ne sont pas compatibles %1$s. C\'est pourquoi nous utilisons automatiquement le multihop pour activer %1$s avec n\'importe quel serveur.</string> <string name="daita_info">Si vous activez « %1$s », vous devez sélectionner manuellement un serveur avec %2$s activé. Vous risquez alors de vous retrouver dans une situation de blocage tant que vous n\'avez pas sélectionné un serveur compatible dans la vue « Sélectionner une localisation ».</string> <string name="daita_multihop">%1$s : multihop</string> + <string name="days_were_added_30">30 jours ont été ajoutés à votre compte.</string> <string name="delete">Supprimer</string> <string name="delete_custom_list_confirmation_description">Supprimer « %1$s » ?</string> <string name="delete_custom_list_message">« %1$s » a été supprimé</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Échec de la connexion</string> <string name="login_title">Connexion</string> <string name="malware_info">Avertissement : le bloqueur de malware n\'est pas un anti-virus et ne doit pas être traité comme tel, il s\'agit juste d\'une couche de protection supplémentaire.</string> - <string name="manage_account">Gérer le compte</string> <string name="manage_devices">Gérer les appareils</string> <string name="manage_devices_confirm_removal_description_line1">Supprimer %1$s ?</string> <string name="manage_devices_confirm_removal_description_line2">L\'appareil sera supprimé de la liste et déconnecté.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Pour commencer à utiliser l\'application, vous devez d\'abord ajouter du temps à votre compte.</string> <string name="payment_billing_error_dialog_message">Nous n\'avons pas pu lancer le processus de paiement, merci de vérifier que vous disposez de la dernière version de Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play indisponible</string> - <string name="payment_completed_dialog_message">30 jours ont été ajoutés à votre compte.</string> - <string name="payment_completed_dialog_title">Le temps a bien été ajouté</string> <string name="payment_obfuscation_id_error_dialog_message">Nous n\'avons pas pu lancer le processus de paiement, merci de réessayer plus tard.</string> <string name="payment_obfuscation_id_error_dialog_title">Services Mullvad indisponibles</string> - <string name="payment_pending_dialog_message">Nous vérifions actuellement votre achat, ce qui peut prendre un certain temps. Votre temps sera ajouté si la vérification réussit.</string> - <string name="payment_status_pending">Paiement Google Play en attente</string> + <string name="payment_status_pending_short">Paiement Google Play en attente</string> <string name="please_enter_a_valid_ip_address">Merci de saisir une adresse IPv4 ou IPv6 valide</string> <string name="please_enter_a_valid_remote_server_port">Merci de saisir un port de serveur distant valide</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index 1d7cfbf528..5a58201d45 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Mostra promemoria quando il tempo dell\'account sta per scadere</string> <string name="account_time_notification_channel_name">Promemoria temporali per l\'account</string> <string name="add">Aggiungi</string> - <string name="add_30_days_time">Aggiungi 30 giorni di tempo</string> <string name="add_30_days_time_x">Aggiungi 30 giorni di tempo (%1$s)</string> <string name="add_a_server">Aggiungi un server</string> <string name="add_dns_server_dialog_title">Aggiungi server DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Non tutti i nostri server sono abilitati per %1$s. Pertanto, utilizziamo automaticamente il multihop per abilitare %1$s con un server qualsiasi.</string> <string name="daita_info">Abilitando “%1$s” dovrai selezionare manualmente un server abilitato per %2$s. Questo può comportare uno stato di blocco finché non selezioni un server compatibile nella vista “Seleziona posizione”.</string> <string name="daita_multihop">%1$s: Multihop</string> + <string name="days_were_added_30">30 giorni aggiunti al tuo account.</string> <string name="delete">Elimina</string> <string name="delete_custom_list_confirmation_description">Eliminare \"%1$s\"?</string> <string name="delete_custom_list_message">\"%1$s\" eliminato</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Accesso non riuscito</string> <string name="login_title">Accedi</string> <string name="malware_info">Avvertenza: questa protezione dai malware non è un antivirus e non deve essere trattata come tale, è solo un ulteriore livello di protezione.</string> - <string name="manage_account">Gestisci account</string> <string name="manage_devices">Gestisci dispositivi</string> <string name="manage_devices_confirm_removal_description_line1">Rimuovere %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Il dispositivo verrà rimosso dall\'elenco e disconnesso.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Per iniziare a utilizzare l\'app, devi prima aggiungere tempo al tuo account.</string> <string name="payment_billing_error_dialog_message">Non siamo riusciti ad avviare il processo di pagamento, assicurati di avere la versione più recente di Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play non disponibile</string> - <string name="payment_completed_dialog_message">30 giorni aggiunti al tuo account.</string> - <string name="payment_completed_dialog_title">L\'ora è stata aggiunta correttamente</string> <string name="payment_obfuscation_id_error_dialog_message">Non siamo riusciti ad avviare il processo di pagamento, riprova più tardi.</string> <string name="payment_obfuscation_id_error_dialog_title">Servizi Mullvad non disponibili</string> - <string name="payment_pending_dialog_message">Stiamo verificando il tuo acquisto, l\'operazione potrebbe richiedere del tempo. Il tuo tempo verrà aggiunto quando la verifica avrà avuto esito positivo.</string> - <string name="payment_status_pending">Pagamento Google Play in sospeso</string> + <string name="payment_status_pending_short">Pagamento Google Play in sospeso</string> <string name="please_enter_a_valid_ip_address">Inserisci un indirizzo IPv4 o IPv6 valido</string> <string name="please_enter_a_valid_remote_server_port">Inserisci una porta di server remoto valida</string> <string name="port">Porta</string> diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index 7a04ad5dd8..314b1f5d1b 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">アカウントの期限切れが迫っているときにリマインダーを表示します</string> <string name="account_time_notification_channel_name">アカウント時間のリマインダー</string> <string name="add">追加</string> - <string name="add_30_days_time">30日分を追加する</string> <string name="add_30_days_time_x">30日分を追加する (%1$s)</string> <string name="add_a_server">サーバーを追加</string> <string name="add_dns_server_dialog_title">DNS サーバーを追加</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">すべてのサーバーが%1$sに対応しているわけではないため、どのサーバーでも%1$sが有効になるようにマルチホップを自動的に使用しています。</string> <string name="daita_info">“%1$s” を有効化した場合、%2$s対応のサーバーを手動で選択する必要があります。これにより、“場所を選択する” で互換性のあるサーバーを選択するまでブロック状態となる可能性があります。</string> <string name="daita_multihop">%1$s: マルチホップ</string> + <string name="days_were_added_30">アカウントに30日分が追加されました。</string> <string name="delete">削除</string> <string name="delete_custom_list_confirmation_description">\"%1$s\" を削除しますか?</string> <string name="delete_custom_list_message">\"%1$s\" は削除されました</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">ログインに失敗しました</string> <string name="login_title">ログイン</string> <string name="malware_info">警告: マルウェアブロッカーはウィルス対策ではありませんので、そのような用途には使用しないでください。あくまで追加の保護レイヤーに過ぎません。</string> - <string name="manage_account">アカウントを管理する</string> <string name="manage_devices">デバイスを管理する</string> <string name="manage_devices_confirm_removal_description_line1">%1$sを削除しますか?</string> <string name="manage_devices_confirm_removal_description_line2">リストからデバイスが削除され、ログアウトされます。</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">アプリを使い始めるには、まずはアカウントに時間を追加する必要があります。</string> <string name="payment_billing_error_dialog_message">決済処理を開始できませんでした。最新バージョンのGoogle Playを使用していることを確認してください。</string> <string name="payment_billing_error_dialog_title">Google Playを使用できません</string> - <string name="payment_completed_dialog_message">アカウントに30日分が追加されました。</string> - <string name="payment_completed_dialog_title">時間を正常に追加しました</string> <string name="payment_obfuscation_id_error_dialog_message">決済処理を開始できませんでした。後でもう一度お試しください。</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvadサービスを使用できません</string> - <string name="payment_pending_dialog_message">購入を確認中です。これにはしばらく時間がかかる場合があります。正常に確認されると、この時間が追加されます。</string> - <string name="payment_status_pending">Google Playの決済は未完了です</string> + <string name="payment_status_pending_short">Google Playの決済は未完了です</string> <string name="please_enter_a_valid_ip_address">有効な IPv4 または IPv6 アドレスを入力してください</string> <string name="please_enter_a_valid_remote_server_port">有効なリモートサーバーのポートを入力してください</string> <string name="port">ポート</string> diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index 292d76e24c..89c5766c84 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">계정 시간이 만료되려고 할 때 알림 표시</string> <string name="account_time_notification_channel_name">계정 시간 알림</string> <string name="add">추가</string> - <string name="add_30_days_time">30일 시간 추가</string> <string name="add_30_days_time_x">30일 시간 추가(%1$s)</string> <string name="add_a_server">서버 추가</string> <string name="add_dns_server_dialog_title">DNS 서버 추가</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">일부 서버에는 %1$s가 활성화되어 있지 않습니다. 따라서 당사는 모든 서버에서 %1$s를 활성화하기 위해 자동으로 멀티홉을 사용합니다.</string> <string name="daita_info">“%1$s”을 활성화하면 %2$s가 활성화된 서버를 수동으로 선택해야 합니다. 이로 인해 “위치 선택” 창에서 호환되는 서버를 선택할 때까지 차단된 상태에 빠질 수 있습니다.</string> <string name="daita_multihop">%1$s: 멀티홉</string> + <string name="days_were_added_30">귀하의 계정에 30일이 추가되었습니다.</string> <string name="delete">삭제</string> <string name="delete_custom_list_confirmation_description">\"%1$s\"을(를) 삭제하시겠습니까?</string> <string name="delete_custom_list_message">\"%1$s\"이(가) 삭제되었습니다</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">로그인 실패</string> <string name="login_title">로그인</string> <string name="malware_info">경고: 맬웨어 차단기는 안티바이러스가 아니며 하나의 추가 보호 계층일 뿐입니다.</string> - <string name="manage_account">계정 관리</string> <string name="manage_devices">장치 관리</string> <string name="manage_devices_confirm_removal_description_line1">%1$s을(를) 제거하시겠습니까?</string> <string name="manage_devices_confirm_removal_description_line2">장치가 목록에서 제거되고 로그아웃됩니다.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">앱 사용을 시작하려면, 먼저 계정에 시간을 추가해야 합니다.</string> <string name="payment_billing_error_dialog_message">결제 프로세스를 시작할 수 없습니다. Google Play가 최신 버전인지 확인하세요.</string> <string name="payment_billing_error_dialog_title">Google Play 사용 불가</string> - <string name="payment_completed_dialog_message">귀하의 계정에 30일이 추가되었습니다.</string> - <string name="payment_completed_dialog_title">시간이 성공적으로 추가되었습니다.</string> <string name="payment_obfuscation_id_error_dialog_message">결제 프로세스를 시작할 수 없습니다. 나중에 다시 시도해 주세요.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad 서비스 사용 불가</string> - <string name="payment_pending_dialog_message">구매를 현재 확인하는 중이므로 다소 시간이 걸릴 수 있습니다. 확인을 성공적으로 마치면 시간이 추가됩니다.</string> - <string name="payment_status_pending">Google Play 결제 보류 중</string> + <string name="payment_status_pending_short">Google Play 결제 보류 중</string> <string name="please_enter_a_valid_ip_address">유효한 IPv4 또는 IPv6 주소를 입력하세요</string> <string name="please_enter_a_valid_remote_server_port">유효한 원격 서버 포트를 입력하세요</string> <string name="port">포트</string> diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 22e661b269..28fd3349b4 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">အကောင့်အချိန် သက်တမ်းကုန်ခါနီးချိန်၌ သတိပေးချက်များ ပြသပေးပါသည်</string> <string name="account_time_notification_channel_name">အကောင့်အချိန် သတိပေးချက်များ</string> <string name="add">ပေါင်းထည့်ရန်</string> - <string name="add_30_days_time">အချိန် ရက် 30 ကို ပေါင်းထည့်ရန်</string> <string name="add_30_days_time_x">အချိန် ရက် 30 ကို ပေါင်းထည့်ရန် (%1$s)</string> <string name="add_a_server">ဆာဗာ ပေါင်းထည့်ရန်</string> <string name="add_dns_server_dialog_title">DNS ဆာဗာကို ပေါင်းထည့်ရန်</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">ကျွန်ုပ်တို့၏ဆာဗာအားလုံးတွင် %1$s ကိုဖွင့်ထားခြင်းမရှိပါ။ ထို့ကြောင့် ကျွန်ုပ်တို့သည် မည်သည့်ဆာဗာနှင့်မဆို %1$s ကိုဖွင့်ရန် မာလ်တီဟော့ပ်ကို အလိုအလျောက်အသုံးပြုပါသည်။</string> <string name="daita_info">“%1$s” ကို ဖွင့်လိုက်လျှင် သင်သည် %2$s ကိုဖွင့်ထားသည့် ဆာဗာကို ကိုယ်တိုင်ရွေးချယ်ရမည်ဖြစ်သည်။ ၎င်းသည် \"တည်နေရာကိုရွေးချယ်ပါ\" မြင်ကွင်းတွင် တွဲဖက်သုံးနိုင်သောဆာဗာကို မရွေးချယ်ရသေးမချင်း ပိတ်ဆို့ခံထားသောအခြေအနေတွင် အဆုံးသတ်စေနိုင်သည်။</string> <string name="daita_multihop">%1$s: မာလ်တီဟော့ပ်</string> + <string name="days_were_added_30">သင့်အကောင့်ထဲသို့ ရက် 30 ကို ပေါင်းထည့်ပြီးပါပြီ။</string> <string name="delete">ဖျက်ရန်</string> <string name="delete_custom_list_confirmation_description">\"%1$s\" ကို ဖျက်မည်လား။</string> <string name="delete_custom_list_message">\"%1$s\" ကို ဖျက်ပြီးပါပြီ</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">ဝင်ရောက်မှု မအောင်မြင်ပါ</string> <string name="login_title">ဝင်ရန်</string> <string name="malware_info">သတိပေးချက်- မဲလ်ဝဲရ် ပိတ်ဆို့မှုသည် အပိုအကာအကွယ်လွှာ တစ်ခုသာဖြစ်ပြီး ဗိုင်းရပ်စ် ကာကွယ်ရေး (anti-virus) မဟုတ်၍ ၎င်းအဖြစ် မမှတ်ယူသင့်ပါ။</string> - <string name="manage_account">အကောင့် စီမံခန့်ခွဲရန်</string> <string name="manage_devices">စက်များကို စီမံရန်</string> <string name="manage_devices_confirm_removal_description_line1">%1$s ကို ဖယ်ရှားမလား။</string> <string name="manage_devices_confirm_removal_description_line2">စက်ကို စာရင်းမှ ဖယ်ရှားပြီး စနစ်မှ ထွက်သွားလိမ့်မည်။</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">အက်ပ်ကို စသုံးရန်အတွက် ဦးစွာ သင့်အကောင့်တွင် အချိန်ပေါင်းထည့်ပေးရန် လိုအပ်ပါသည်။</string> <string name="payment_billing_error_dialog_message">လက်ရှိတွင် ပေးချေမှု လုပ်ငန်းစဉ်ကို စတင်၍ မရနိုင်ပါ၊ Google Play နောက်ဆုံး ဗားရှင်း သင့်တွင်ရှိနေကြောင်း သေချာပါစေ။</string> <string name="payment_billing_error_dialog_title">Google Play ကို မရရှိနိုင်ပါ</string> - <string name="payment_completed_dialog_message">သင့်အကောင့်ထဲသို့ ရက် 30 ကို ပေါင်းထည့်ပြီးပါပြီ။</string> - <string name="payment_completed_dialog_title">အချိန်ကို အောင်မြင်စွာ ပေါင်းထည့်ပြီးပြီ</string> <string name="payment_obfuscation_id_error_dialog_message">လက်ရှိတွင် ပေးချေမှု လုပ်ငန်းစဉ်ကို စတင်၍ မရနိုင်ပါ၊ နောက်မှ ထပ်ကြိုးစားကြည့်ပါ။</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad ဝန်ဆောင်မှုများကို မရရှိနိုင်ပါ</string> - <string name="payment_pending_dialog_message">သင့်ဝယ်ယူမှုကို လက်ရှိတွင် ကျွန်ုပ်တို့ စစ်ဆေး အတည်ပြုနေဆဲဖြစ်ပြီး အချိန်အနည်းငယ်ကြာနိုင်ပါသည်။ စစ်ဆေး အတည်ပြုမှု အောင်မြင်ပါက သင့်အချိန်များကို ပေါင်းထည့်သွားပါမည်။</string> - <string name="payment_status_pending">Google Play ပေးချေမှုကို ဆိုင်းငံ့ထားဆဲ</string> + <string name="payment_status_pending_short">Google Play ပေးချေမှုကို ဆိုင်းငံ့ထားဆဲ</string> <string name="please_enter_a_valid_ip_address">မှန်ကန်သော IPv4 သို့မဟုတ် IPv6 လိပ်စာကို ရိုက်ထည့်ပေးပါ</string> <string name="please_enter_a_valid_remote_server_port">မှန်ကန်သော အဝေးဆာဗာ ပေါ့တ်ကို ရိုက်ထည့်ပေးပါ</string> <string name="port">ပေါ့တ်</string> diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 81459c2484..6b55ea807a 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Viser påminnelser når tidsavbrudd for kontoen er i ferd med å inntreffe</string> <string name="account_time_notification_channel_name">Påminnelser om tidsavbrudd for konto</string> <string name="add">Legg til</string> - <string name="add_30_days_time">Legg til 30 dager</string> <string name="add_30_days_time_x">Legg til 30 dager (%1$s)</string> <string name="add_a_server">Legg til en server</string> <string name="add_dns_server_dialog_title">Legg til DNS-server</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Ikke alle serverne våre er %1$s-aktiverte. Derfor bruker vi automatisk multihopp for å aktivere %1$s med alle servere.</string> <string name="daita_info">Hvis du aktiverer «%1$s», må du manuelt velge en server som har aktivert %2$s. Dette kan føre til at du havner i en blokkert tilstand inntil du har valgt en kompatibel server under «Velg plassering».</string> <string name="daita_multihop">%1$s: Multihopp</string> + <string name="days_were_added_30">30 dager ble lagt til kontoen din.</string> <string name="delete">Slett</string> <string name="delete_custom_list_confirmation_description">Slette «%1$s»?</string> <string name="delete_custom_list_message">«%1$s» ble slettet</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Kunne ikke logge inn</string> <string name="login_title">Logg inn</string> <string name="malware_info">Advarsel: Blokkeringen av skadelig programvare er ikke et antivirusprogram og skal ikke brukes som dette. Det er bare et ekstra lag med beskyttelse.</string> - <string name="manage_account">Administrer konto</string> <string name="manage_devices">Behandle enheter</string> <string name="manage_devices_confirm_removal_description_line1">Fjerne %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Enheten blir fjernet fra listen og logget ut.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">For å starte bruken av appen, må du først legge til tid til kontoen.</string> <string name="payment_billing_error_dialog_message">Vi kunne ikke starte betalingsprosessen. Kontroller om du har siste versjon av Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play utilgjengelig</string> - <string name="payment_completed_dialog_message">30 dager ble lagt til kontoen din.</string> - <string name="payment_completed_dialog_title">Tid ble lagt til</string> <string name="payment_obfuscation_id_error_dialog_message">Vi kunne ikke starte betalingsprosessen. Prøv igjen senere.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-tjenester utilgjengelig</string> - <string name="payment_pending_dialog_message">Vi behandler kjøpet. Det kan ta litt tid. Hvis kjøpet blir bekreftet, legges tiden din til.</string> - <string name="payment_status_pending">Google Play-betaling venter</string> + <string name="payment_status_pending_short">Google Play-betaling venter</string> <string name="please_enter_a_valid_ip_address">Skriv inn en gyldig IPv4- eller IPv6-adresse</string> <string name="please_enter_a_valid_remote_server_port">Skriv inn en gyldig ekstern server-port</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index cbf90e2fc7..1499e4b26e 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Toont herinneringen wanneer de accounttijd op het punt staat te verlopen</string> <string name="account_time_notification_channel_name">Accounttijdherinneringen</string> <string name="add">Toevoegen</string> - <string name="add_30_days_time">30 dagen tijd toevoegen</string> <string name="add_30_days_time_x">30 dagen tijd toevoegen (%1$s)</string> <string name="add_a_server">Server toevoegen</string> <string name="add_dns_server_dialog_title">DNS-server toevoegen</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Niet al onze servers zijn geschikt voor %1$s. Daarom gebruiken we automatisch multihop om %1$s in te schakelen bij elke server.</string> <string name="daita_info">Als u \"%1$s\" inschakelt, moet u handmatig een server selecteren die %2$s ondersteunt. Hierdoor kunt u in een geblokkeerde toestand terechtkomen totdat u een compatibele server hebt geselecteerd in het dialoogvenster \"Locatie selecteren\".</string> <string name="daita_multihop">%1$s: multihop</string> + <string name="days_were_added_30">Er zijn 30 dagen toegevoegd aan uw account.</string> <string name="delete">Verwijderen</string> <string name="delete_custom_list_confirmation_description">\"%1$s\" verwijderen?</string> <string name="delete_custom_list_message">\"%1$s\" is verwijderd</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Aanmelden mislukt</string> <string name="login_title">Aanmelden</string> <string name="malware_info">Waarschuwing: de malwareblocker is geen antivirus en mag niet als zodanig behandeld worden. Dit is slechts een extra beschermingslaag.</string> - <string name="manage_account">Account beheren</string> <string name="manage_devices">Apparaten beheren</string> <string name="manage_devices_confirm_removal_description_line1">%1$s verwijderen?</string> <string name="manage_devices_confirm_removal_description_line2">Het apparaat wordt uit de lijst verwijderd en wordt uitgelogd.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Om de app te gebruiken, moet u eerst tijd toevoegen aan uw account.</string> <string name="payment_billing_error_dialog_message">We kunnen het betalingsproces niet starten. Controleer of u de nieuwste versie van Google Play hebt.</string> <string name="payment_billing_error_dialog_title">Google Play niet beschikbaar</string> - <string name="payment_completed_dialog_message">Er zijn 30 dagen toegevoegd aan uw account.</string> - <string name="payment_completed_dialog_title">Tijd is toegevoegd</string> <string name="payment_obfuscation_id_error_dialog_message">We kunnen het betalingsproces niet starten, probeer het later opnieuw.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-diensten niet beschikbaar</string> - <string name="payment_pending_dialog_message">We verifiëren momenteel uw aankoop, dit kan even duren. Uw tijd wordt toegevoegd als de controle succesvol is.</string> - <string name="payment_status_pending">Google Play-betaling in behandeling</string> + <string name="payment_status_pending_short">Google Play-betaling in behandeling</string> <string name="please_enter_a_valid_ip_address">Voer een geldig IPv4- of IPv6-adres in</string> <string name="please_enter_a_valid_remote_server_port">Voer een geldige poort op de externe server in</string> <string name="port">Poort</string> diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 13c1a24c16..57de2c4691 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Pokazuje przypomnienia, gdy kończy się czas na koncie</string> <string name="account_time_notification_channel_name">Przypomnienia o czasie na koncie</string> <string name="add">Dodaj</string> - <string name="add_30_days_time">Dodaj 30 dni</string> <string name="add_30_days_time_x">Dodaj 30 dni (%1$s)</string> <string name="add_a_server">Dodaj serwer</string> <string name="add_dns_server_dialog_title">Dodaj serwer DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Nie wszystkie nasze serwery obsługują %1$s. Dlatego automatycznie używamy funkcji wielokrotnego przeskoku, aby umożliwić działanie %1$s z dowolnym serwerem.</string> <string name="daita_info">Po włączeniu opcji „%1$s” trzeba ręcznie wybrać serwer, który obsługuje %2$s. Może to skutkować zablokowaniem, dopóki nie wybierzesz zgodnego serwera w widoku „Wybierz lokalizację”.</string> <string name="daita_multihop">%1$s: wielokrotny przeskok</string> + <string name="days_were_added_30">Do konta dodano 30 dni.</string> <string name="delete">Usuń</string> <string name="delete_custom_list_confirmation_description">Usunąć „%1$s”?</string> <string name="delete_custom_list_message">Usunięto „%1$s”</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Błąd logowania</string> <string name="login_title">Logowanie</string> <string name="malware_info">Ostrzeżenie: funkcja blokowania złośliwego oprogramowania nie jest programem antywirusowym i nie należy jej tak traktować. To jedynie dodatkowa warstwa zabezpieczeń.</string> - <string name="manage_account">Zarządzaj kontem</string> <string name="manage_devices">Zarządzaj urządzeniami</string> <string name="manage_devices_confirm_removal_description_line1">Usunąć %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Urządzenie zostanie usunięte z listy i wylogowane.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Aby rozpocząć korzystanie z aplikacji, musisz najpierw dodać czas do swojego konta.</string> <string name="payment_billing_error_dialog_message">Nie mogliśmy rozpocząć procesu płatności. Upewnij się, że masz najnowszą wersję aplikacji Google Play.</string> <string name="payment_billing_error_dialog_title">Sklep Google Play jest niedostępny</string> - <string name="payment_completed_dialog_message">Do konta dodano 30 dni.</string> - <string name="payment_completed_dialog_title">Dodano czas</string> <string name="payment_obfuscation_id_error_dialog_message">Nie mogliśmy rozpocząć procesu płatności. Spróbuj ponownie później.</string> <string name="payment_obfuscation_id_error_dialog_title">Usługi Mullvad są niedostępne</string> - <string name="payment_pending_dialog_message">Weryfikujemy zakup. Może to zająć trochę czasu. Jeśli weryfikacja powiedzie się, czas zostanie dodany.</string> - <string name="payment_status_pending">Oczekiwanie na płatność Google Play</string> + <string name="payment_status_pending_short">Oczekiwanie na płatność Google Play</string> <string name="please_enter_a_valid_ip_address">Wprowadź prawidłowy adres IPv4 lub IPv6</string> <string name="please_enter_a_valid_remote_server_port">Wprowadź prawidłowy port serwera zdalnego</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 43fddb76ad..603b10c382 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Mostra lembretes quando o tempo da conta está prestes a expirar</string> <string name="account_time_notification_channel_name">Lembretes de tempo da conta</string> <string name="add">Adicionar</string> - <string name="add_30_days_time">Adicionar 30 dias</string> <string name="add_30_days_time_x">Adicionar 30 dias (%1$s)</string> <string name="add_a_server">Adicionar um servidor</string> <string name="add_dns_server_dialog_title">Adicionar servidor DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Nem todos os servidores suportam %1$s. Por isso, utilizamos multihop automaticamente para ativar %1$s em qualquer servidor.</string> <string name="daita_info">Ao ativar “%1$s”, terá de selecionar manualmente um servidor que tenha %2$s ativada, o que pode fazer com que fique num estado bloqueado até selecionar um servidor compatível na vista \"Selecionar localização\".</string> <string name="daita_multihop">%1$s: multihop</string> + <string name="days_were_added_30">30 dias adicionados à sua conta.</string> <string name="delete">Eliminar</string> <string name="delete_custom_list_confirmation_description">Eliminar \"%1$s\"?</string> <string name="delete_custom_list_message">\"%1$s\" foi eliminada</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Erro ao iniciar sessão</string> <string name="login_title">Iniciar sessão</string> <string name="malware_info">Aviso: o bloqueador de malware não é um antivírus e não deve ser tratado como tal, é apenas uma camada extra de proteção.</string> - <string name="manage_account">Gerir conta</string> <string name="manage_devices">Gerir dispositivos</string> <string name="manage_devices_confirm_removal_description_line1">Remover %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">O dispositivo será removido da lista e terminará a sessão.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Para começar a utilizar a aplicação, primeiro tem de adicionar tempo à sua conta.</string> <string name="payment_billing_error_dialog_message">Não foi possível iniciar o processo de pagamento. Verifique se tem a versão mais recente do Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play indisponível</string> - <string name="payment_completed_dialog_message">30 dias adicionados à sua conta.</string> - <string name="payment_completed_dialog_title">Tempo adicionado com sucesso</string> <string name="payment_obfuscation_id_error_dialog_message">Não foi possível iniciar o processo de pagamento, tente novamente mais tarde.</string> <string name="payment_obfuscation_id_error_dialog_title">Serviços Mullvad indisponíveis</string> - <string name="payment_pending_dialog_message">Estamos atualmente a verificar a sua compra, o que poderá demorar algum tempo. O seu tempo será adicionado se a verificação for bem sucedida.</string> - <string name="payment_status_pending">Pagamento Google Play pendente</string> + <string name="payment_status_pending_short">Pagamento Google Play pendente</string> <string name="please_enter_a_valid_ip_address">Introduza um endereço IPv4 ou IPv6 válido</string> <string name="please_enter_a_valid_remote_server_port">Introduza uma porta de servidor remoto válida</string> <string name="port">Porta</string> diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index a78c8f83f4..e54c2d71f2 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Показывает уведомления, когда время на учетной записи скоро закончится</string> <string name="account_time_notification_channel_name">Напоминания о времени на учетной записи</string> <string name="add">Добавить</string> - <string name="add_30_days_time">Добавить 30 дней</string> <string name="add_30_days_time_x">Добавить 30 дней (%1$s)</string> <string name="add_a_server">Добавить сервер</string> <string name="add_dns_server_dialog_title">Добавить DNS-сервер</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">%1$s поддерживается не на всех серверах. Поэтому, чтобы функция %1$s работала с любым сервером, мы автоматически используем многократный переход.</string> <string name="daita_info">После включения параметра «%1$s» вы должны будете вручную выбрать сервер, который поддерживает %2$s. В результате вы можете оказаться заблокированы, пока не выберете совместимый сервер в окне выбора местоположения.</string> <string name="daita_multihop">%1$s: многократный переход</string> + <string name="days_were_added_30">На учетную запись добавлено 30 дней.</string> <string name="delete">Удалить</string> <string name="delete_custom_list_confirmation_description">Удалить список «%1$s»?</string> <string name="delete_custom_list_message">Список «%1$s» удален</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Ошибка входа</string> <string name="login_title">Вход</string> <string name="malware_info">Внимание! Блокировщик вредоносного ПО — это просто дополнительный уровень защиты, а не антивирус.</string> - <string name="manage_account">Управление учетной записью</string> <string name="manage_devices">Управление устройствами</string> <string name="manage_devices_confirm_removal_description_line1">Удалить устройство «%1$s»?</string> <string name="manage_devices_confirm_removal_description_line2">Устройство будет удалено из списка. На нем будет выполнен выход из системы.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Чтобы пользоваться приложением, нужно добавить время на учетную запись.</string> <string name="payment_billing_error_dialog_message">Не удалось начать процесс оплаты — убедитесь, что у вас установлена последняя версия Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play недоступен</string> - <string name="payment_completed_dialog_message">На учетную запись добавлено 30 дней.</string> - <string name="payment_completed_dialog_title">Время добавлено</string> <string name="payment_obfuscation_id_error_dialog_message">Не удалось начать процесс оплаты. Повторите попытку позже.</string> <string name="payment_obfuscation_id_error_dialog_title">Службы Mullvad недоступны</string> - <string name="payment_pending_dialog_message">Сейчас мы проверяем, прошла ли оплата; нужно немного подождать. Если проверка завершится успешно, мы добавим оплаченное время.</string> - <string name="payment_status_pending">Ожидается оплата в Google Play</string> + <string name="payment_status_pending_short">Ожидается оплата в Google Play</string> <string name="please_enter_a_valid_ip_address">Введите действительный адрес IPv4 или IPv6</string> <string name="please_enter_a_valid_remote_server_port">Введите действительный порт удаленного сервера</string> <string name="port">Порт</string> diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index ee71d70e7e..d7472ba1bd 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Visar påminnelser när kontots tidsgräns uppnås</string> <string name="account_time_notification_channel_name">Påminnelser om kontotid</string> <string name="add">Lägg till</string> - <string name="add_30_days_time">Lägg till 30 dagar</string> <string name="add_30_days_time_x">Lägg till 30 dagar (%1$s)</string> <string name="add_a_server">Lägg till en server</string> <string name="add_dns_server_dialog_title">Lägg till DNS-server</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">Det är inte alla våra servrar som är %1$s-aktiverade. Därför använder vi multihopp automatiskt för att aktivera %1$s med alla servrar.</string> <string name="daita_info">Om du aktiverar \"%1$s\" måste du manuellt välja en server som är %2$s-aktiverad. Det kan leda till ett blockerat tillstånd tills du väljer en kompatibel server i \"Välj plats\"-vyn.</string> <string name="daita_multihop">%1$s: Multihopp</string> + <string name="days_were_added_30">30 dagar har lagts till i ditt konto.</string> <string name="delete">Ta bort</string> <string name="delete_custom_list_confirmation_description">Ta bort \"%1$s\"?</string> <string name="delete_custom_list_message">\"%1$s\" har tagits bort</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Inloggningen misslyckades</string> <string name="login_title">Logga in</string> <string name="malware_info">Varning! Blockering av skadlig kod är inte ett antivirusprogram och bör inte behandlas som ett. Det här är bara ett extra skyddslager.</string> - <string name="manage_account">Hantera konto</string> <string name="manage_devices">Hantera enheter</string> <string name="manage_devices_confirm_removal_description_line1">Ta bort %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">Enheten kommer att tas bort från listan och loggas ut.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Om du vill börja använda appen måste du först lägga till tid i ditt konto.</string> <string name="payment_billing_error_dialog_message">Vi kunde inte starta betalningsprocessen. Se till att du har den senaste versionen av Google Play.</string> <string name="payment_billing_error_dialog_title">Google Play är inte tillgängligt</string> - <string name="payment_completed_dialog_message">30 dagar har lagts till i ditt konto.</string> - <string name="payment_completed_dialog_title">Tid har lagts till</string> <string name="payment_obfuscation_id_error_dialog_message">Vi kunde inte starta betalningsprocessen, försök igen senare.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad-tjänster är inte tillgängliga</string> - <string name="payment_pending_dialog_message">Vi verifierar ditt köp just nu och det kan ta en stund. Din tid läggs till om verifieringen lyckas.</string> - <string name="payment_status_pending">Google Play-betalning väntar</string> + <string name="payment_status_pending_short">Google Play-betalning väntar</string> <string name="please_enter_a_valid_ip_address">Ange en giltig IPv4- eller IPv6-adress</string> <string name="please_enter_a_valid_remote_server_port">Ange en giltig fjärrserverport</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index b83d785055..d93414e153 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">แสดงการแจ้งเตือน ในขณะที่เวลาบัญชีใกล้หมดอายุ</string> <string name="account_time_notification_channel_name">การแจ้งเตือนเวลาบัญชี</string> <string name="add">เพิ่ม</string> - <string name="add_30_days_time">เพิ่มเวลา 30 วัน</string> <string name="add_30_days_time_x">เพิ่มเวลา 30 วัน (%1$s)</string> <string name="add_a_server">เพิ่มเซิร์ฟเวอร์</string> <string name="add_dns_server_dialog_title">เพิ่มเซิร์ฟเวอร์ DNS</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">ไม่ใช่ทุกเซิร์ฟเวอร์ของเราที่เปิดใช้งาน %1$s ด้วยเหตุนี้เอง เราจึงใช้การมัลติฮอปอัตโนมัติ เพื่อเปิดใช้งาน %1$s กับทุกเซิร์ฟเวอร์</string> <string name="daita_info">โดยการเปิดใช้งาน “%1$s” คุณจะต้องเลือกเซิร์ฟเวอร์ที่เปิดใช้งาน %2$s ด้วยตนเอง ซึ่งอาจทำให้คุณอยู่ในสถานะถูกบล็อก จนกว่าคุณจะเลือกเซิร์ฟเวอร์ที่เข้ากันได้ในมุมมอง \"เลือกตำแหน่งที่ตั้ง\"</string> <string name="daita_multihop">%1$s: Multihop</string> + <string name="days_were_added_30">30 วัน ถูกเพิ่มลงในบัญชีของคุณแล้ว</string> <string name="delete">ลบ</string> <string name="delete_custom_list_confirmation_description">ลบ \"%1$s\" หรือไม่</string> <string name="delete_custom_list_message">\"%1$s\" ถูกลบแล้ว</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">การเข้าสู่ระบบล้มเหลว</string> <string name="login_title">เข้าสู่ระบบ</string> <string name="malware_info">คำเตือน: ตัวบล็อกมัลแวร์ไม่ใช่แอนตี้ไวรัส และไม่ควรนำมาใช้ในรูปแบบดังกล่าว นี่เป็นเพียงชั้นการป้องกันเพิ่มเติมเท่านั้น</string> - <string name="manage_account">จัดการบัญชี</string> <string name="manage_devices">จัดการอุปกรณ์</string> <string name="manage_devices_confirm_removal_description_line1">ลบ %1$s หรือไม่</string> <string name="manage_devices_confirm_removal_description_line2">อุปกรณ์จะถูกลบออกจากรายการและออกจากระบบ</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">คุณจำเป็นต้องเพิ่มเวลาไปยังบัญชีของคุณก่อน เพื่อที่จะเริ่มใช้งานแอป</string> <string name="payment_billing_error_dialog_message">เราไม่สามารถเริ่มกระบวนการชำระเงินได้ โปรดตรวจสอบให้แน่ใจว่า คุณมี Google Play เวอร์ชันล่าสุด </string> <string name="payment_billing_error_dialog_title">Google Play ไม่พร้อมใช้งาน</string> - <string name="payment_completed_dialog_message">30 วัน ถูกเพิ่มลงในบัญชีของคุณแล้ว</string> - <string name="payment_completed_dialog_title">เพิ่มเวลาสำเร็จแล้ว</string> <string name="payment_obfuscation_id_error_dialog_message">เราไม่สามารถเริ่มกระบวนการชำระเงินได้ โปรดลองอีกครั้งในภายหลัง</string> <string name="payment_obfuscation_id_error_dialog_title">บริการ Mullavad ไม่พร้อมใช้งาน</string> - <string name="payment_pending_dialog_message">เรากำลังตรวจสอบยืนยันการซื้อของคุณ ซึ่งอาจใช้เวลาสักครู่ คุณจะได้รับเวลาเพิ่ม หากการตรวจสอบยืนยันสำเร็จ</string> - <string name="payment_status_pending">กำลังชำระเงิน Google Play</string> + <string name="payment_status_pending_short">กำลังชำระเงิน Google Play</string> <string name="please_enter_a_valid_ip_address">โปรดป้อนที่อยู่ IPv4 หรือ IPv6 ที่ถูกต้อง</string> <string name="please_enter_a_valid_remote_server_port">โปรดป้อนพอร์ตเซิร์ฟเวอร์ระยะไกลที่ถูกต้อง</string> <string name="port">พอร์ต</string> diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 37f2b7c405..ed11675184 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">Hesap süresinin dolmak üzere olduğunu bildiren hatırlatıcıları gösterir</string> <string name="account_time_notification_channel_name">Hesap süresi hatırlatıcıları</string> <string name="add">Ekle</string> - <string name="add_30_days_time">30 gün süre ekleyin</string> <string name="add_30_days_time_x">30 gün süre ekleyin (%1$s)</string> <string name="add_a_server">Sunucu ekle</string> <string name="add_dns_server_dialog_title">DNS sunucusu ekle</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">%1$s, tüm sunucularımızda etkin değildir. Bu nedenle, %1$s özelliğini herhangi bir sunucuda etkinleştirmek için otomatik olarak çoklu geçişi kullanırız.</string> <string name="daita_info">\"%1$s\" seçeneğini etkinleştirdiğinizde %2$s özellikli bir sunucuyu manuel olarak seçmeniz gerekir. Bu, \"Konum seç\" görünümünde uyumlu bir sunucu seçilene kadar engellenmiş durumda kalmanıza neden olabilir.</string> <string name="daita_multihop">%1$s: Çoklu geçiş</string> + <string name="days_were_added_30">Hesabınıza 30gün eklendi.</string> <string name="delete">Sil</string> <string name="delete_custom_list_confirmation_description">\"%1$s\" silinsin mi\"?</string> <string name="delete_custom_list_message">\"%1$s\" silindi</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">Oturum açma başarısız</string> <string name="login_title">Oturum Aç</string> <string name="malware_info">Uyarı: Kötü amaçlı yazılım engelleyici, virüsten koruma yazılımı değildir ve bu şekilde değerlendirilmemelidir. Sadece ek bir koruma seviyesi sağlamaktadır.</string> - <string name="manage_account">Hesabı yönet</string> <string name="manage_devices">Cihazları yönet</string> <string name="manage_devices_confirm_removal_description_line1">%1$s kaldırılsın mı?</string> <string name="manage_devices_confirm_removal_description_line2">Cihaz listeden kaldırılacak ve oturum kapatılacak.</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">Uygulamayı kullanmaya başlamak için önce hesabınıza süre eklemeniz gerekir.</string> <string name="payment_billing_error_dialog_message">Ödeme işlemini başlatamadık. Lütfen Google Play\'in en son sürümüne sahip olduğunuzdan emin olun.</string> <string name="payment_billing_error_dialog_title">Google Play kullanılamıyor</string> - <string name="payment_completed_dialog_message">Hesabınıza 30gün eklendi.</string> - <string name="payment_completed_dialog_title">Süre başarıyla eklendi</string> <string name="payment_obfuscation_id_error_dialog_message">Ödeme işlemini başlatamadık, lütfen daha sonra tekrar deneyin.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad hizmetleri kullanılamıyor</string> - <string name="payment_pending_dialog_message">Şu anda satın alma işleminizi doğruluyoruz. Bu işlem biraz zaman alabilir. Süreniz, doğrulama işleminin başarılı olması durumunda eklenecektir.</string> - <string name="payment_status_pending">Google Play ödemesi bekleniyor</string> + <string name="payment_status_pending_short">Google Play ödemesi bekleniyor</string> <string name="please_enter_a_valid_ip_address">Lütfen geçerli bir IPv4 veya IPv6 adresi girin</string> <string name="please_enter_a_valid_remote_server_port">Lütfen geçerli bir uzak sunucu portu girin</string> <string name="port">Port</string> diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index d6b900fa6e..ffceb72bde 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">在帐户时间即将到期时显示提醒</string> <string name="account_time_notification_channel_name">帐户时间提醒</string> <string name="add">添加</string> - <string name="add_30_days_time">增加 30 天</string> <string name="add_30_days_time_x">增加 30 天 (%1$s)</string> <string name="add_a_server">添加服务器</string> <string name="add_dns_server_dialog_title">添加 DNS 服务器</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">我们的部分服务器未启用 %1$s。因此,我们使用多跳自动为任何服务器启用 %1$s。</string> <string name="daita_info">启用“%1$s”后,您需要手动选择启用了 %2$s 的服务器。这可能导致您在“选择位置”视图中选择兼容服务器之前处于阻止状态。</string> <string name="daita_multihop">%1$s:多跳</string> + <string name="days_were_added_30">已向您的帐户增加 30 天。</string> <string name="delete">删除</string> <string name="delete_custom_list_confirmation_description">删除“%1$s”?</string> <string name="delete_custom_list_message">“%1$s”已被删除</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">登录失败</string> <string name="login_title">登录</string> <string name="malware_info">警告:恶意软件阻止程序不是防病毒软件,也不应被视为防病毒软件,这只是提供了一层额外的保护。</string> - <string name="manage_account">管理帐户</string> <string name="manage_devices">管理设备</string> <string name="manage_devices_confirm_removal_description_line1">是否移除“%1$s”?</string> <string name="manage_devices_confirm_removal_description_line2">该设备将从列表中移除并退出登录。</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">要开始使用本应用,您首先需要向帐户中充入时间。</string> <string name="payment_billing_error_dialog_message">我们无法启动付款流程,请确保拥有最新版本的 Google Play。</string> <string name="payment_billing_error_dialog_title">Google Play 不可用</string> - <string name="payment_completed_dialog_message">已向您的帐户增加 30 天。</string> - <string name="payment_completed_dialog_title">时间已成功添加</string> <string name="payment_obfuscation_id_error_dialog_message">我们无法启动付款流程,请稍后再试。</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad 服务不可用</string> - <string name="payment_pending_dialog_message">我们目前正在验证您的购买,这可能需要一些时间。如果验证成功,您的时间将增加。</string> - <string name="payment_status_pending">Google Play 付款待处理</string> + <string name="payment_status_pending_short">Google Play 付款待处理</string> <string name="please_enter_a_valid_ip_address">请输入有效的 IPv4 或 IPv6 地址</string> <string name="please_enter_a_valid_remote_server_port">请输入有效的远程服务器端口</string> <string name="port">端口</string> diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index f0b503cac4..6b2c417d9d 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -7,7 +7,6 @@ <string name="account_time_notification_channel_description">在帳戶時間即將到期時顯示提醒</string> <string name="account_time_notification_channel_name">帳戶時間提醒</string> <string name="add">新增</string> - <string name="add_30_days_time">增加 30 天時間</string> <string name="add_30_days_time_x">增加 30 天時間 (%1$s)</string> <string name="add_a_server">新增伺服器</string> <string name="add_dns_server_dialog_title">新增 DNS 伺服器</string> @@ -118,6 +117,7 @@ <string name="daita_description_slide_2_third_paragraph">我們有些伺服器並未啟用 %1$s。因此,我們使用多點跳躍自動來為任意伺服器啟用 %1$s 。</string> <string name="daita_info">啟用「%1$s」後,您必須手動選取已啟用 %2$s 的伺服器。這有可能導致您後來處於封鎖狀態,直到您在「選取位置」檢視圖中選到相容的伺服器為止。</string> <string name="daita_multihop">%1$s:多點跳躍</string> + <string name="days_were_added_30">已為您的帳戶新增 30 天。</string> <string name="delete">刪除</string> <string name="delete_custom_list_confirmation_description">要刪除「%1$s」嗎?</string> <string name="delete_custom_list_message">「%1$s」已刪除</string> @@ -235,7 +235,6 @@ <string name="login_fail_title">登入失敗</string> <string name="login_title">登入</string> <string name="malware_info">警告:惡意軟體封鎖程式並非防毒軟體,只是提供了一層額外保護,不應將其視為防毒軟體。</string> - <string name="manage_account">管理帳戶</string> <string name="manage_devices">管理裝置</string> <string name="manage_devices_confirm_removal_description_line1">是否移除 %1$s?</string> <string name="manage_devices_confirm_removal_description_line2">裝置將從清單中移除並登出。</string> @@ -282,12 +281,9 @@ <string name="pay_to_start_using">需先在帳戶中加時,才能開始使用本應用程式。</string> <string name="payment_billing_error_dialog_message">我們無法啟動付款流程,請確認您是否擁有最新版本的 Google Play。</string> <string name="payment_billing_error_dialog_title">Google Play 無法使用</string> - <string name="payment_completed_dialog_message">已為您的帳戶新增 30 天。</string> - <string name="payment_completed_dialog_title">已成功新增時間</string> <string name="payment_obfuscation_id_error_dialog_message">我們無法啟動付款流程,請稍後再試一次。</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad 服務無法使用</string> - <string name="payment_pending_dialog_message">我們目前正在驗證您的購買,這可能需要一些時間。如果驗證成功,您的時間就會增加。</string> - <string name="payment_status_pending">Google Play 付款尚待處理</string> + <string name="payment_status_pending_short">Google Play 付款尚待處理</string> <string name="please_enter_a_valid_ip_address">請輸入有效的 IPv4 或 IPv6 位址。</string> <string name="please_enter_a_valid_remote_server_port">請輸入有效的遠端伺服器連接埠</string> <string name="port">連接埠</string> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index e7405020d0..bc27d3b396 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -198,7 +198,6 @@ <string name="custom_dns_disable_mode_subtitle">Disable all \"%s\" above to activate this setting.</string> <string name="settings_changes_effect_warning_short">DNS settings might not go into effect immediately</string> <string name="settings_changes_effect_warning_content_blocker">Changes to DNS related settings might not go into effect immediately due to cached results.</string> - <string name="manage_account">Manage account</string> <string name="obfuscation_title">WireGuard obfuscation</string> <string name="obfuscation_info">Obfuscation hides the WireGuard traffic inside another protocol. It can be used to help circumvent censorship and other types of filtering, where a plain WireGuard connection would be blocked.</string> <string name="automatic">Automatic</string> @@ -230,17 +229,15 @@ <string name="top_bar_time_left">Time left: %s</string> <string name="top_bar_device_name">Device name: %s</string> <string name="add_30_days_time_x">Add 30 days time (%s)</string> - <string name="add_30_days_time">Add 30 days time</string> - <string name="payment_completed_dialog_title">Time was successfully added</string> - <string name="payment_completed_dialog_message">30 days was added to your account.</string> <string name="got_it">Got it!</string> <string name="payment_billing_error_dialog_title">Google Play unavailable</string> <string name="payment_billing_error_dialog_message">We were unable to start the payment process, please make sure you have the latest version of Google Play.</string> <string name="payment_obfuscation_id_error_dialog_title">Mullvad services unavailable</string> <string name="payment_obfuscation_id_error_dialog_message">We were unable to start the payment process, please try again later.</string> - <string name="payment_status_pending">Google Play payment pending</string> + <string name="payment_status_pending_long">Google Play payment pending, this might take some time</string> + <string name="payment_status_pending_short">Google Play payment pending</string> <string name="verifying_purchase">Verifying purchase</string> - <string name="payment_pending_dialog_message">We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful.</string> + <string name="payment_pending_dialog_message">We are still verifying your purchase, this might take some time. Your time will be added if the verification is successful.</string> <string name="connecting">Connecting...</string> <string name="loading_verifying">Verifying purchase...</string> <string name="copied_logs_to_clipboard">Copied logs to clipboard</string> @@ -419,4 +416,14 @@ <string name="vpn_settings_not_available">VPN Settings not available on device</string> <string name="wireguard_port_is_not_supported">The selected %s port is not supported, please change it under</string> <string name="wireguard_settings">%s settings.</string> + <string name="add_90_days_time_x">Add 90 days time (%s)</string> + <string name="add_time">Add time</string> + <string name="loading_products">Loading products</string> + <string name="failed_to_load_products">Failed to load products, please try again</string> + <string name="retry">Retry</string> + <string name="days_were_added_30">30 days was added to your account.</string> + <string name="days_were_added_90">90 days was added to your account.</string> + <string name="time_added">Time added</string> + <string name="app_is_blocking_internet">The app is blocking internet, please disconnect first</string> + <string name="in_app_products_unavailable">In-app products unavailable, please make sure you have the latest version of Google Play.</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index e7e38534ba..1fff17afb4 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -8,6 +8,7 @@ data class Dimensions( val accountRowSpacing: Dp = 24.dp, val addIconSize: Dp = 24.dp, val bigIconSize: Dp = 40.dp, + val borderWidth: Dp = 2.dp, val bottomPadding: Dp = 4.dp, val buttonHeight: Dp = 44.dp, val buttonSpacing: Dp = 8.dp, @@ -52,6 +53,8 @@ data class Dimensions( val notificationIconPadding: Dp = 10.dp, val notificationStatusIconSize: Dp = 10.dp, val obfuscationNavigationBoxWidth: Dp = 80.dp, + val outLineButtonBorderWidth: Dp = 1.dp, + val payIconHeight: Dp = 20.dp, val privacyPolicyIconSize: Dp = 16.dp, val problemReportIconToTitlePadding: Dp = 60.dp, val reconnectButtonMinInteractiveComponentSize: Dp = 40.dp, @@ -75,6 +78,7 @@ data class Dimensions( val successIconVerticalPadding: Dp = 26.dp, val switchIconSize: Dp = 24.dp, val switchLocationRetryMinWidth: Dp = 48.dp, + val thinBorderWidth: Dp = 1.dp, val tinyPadding: Dp = 4.dp, val titleIconSize: Dp = 48.dp, val tvDrawerHeaderStartPadding: Dp = 12.dp, diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index ef7fa99b6a..71041171f0 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2760,6 +2760,9 @@ msgstr "" msgid "30 days was added to your account." msgstr "" +msgid "90 days was added to your account." +msgstr "" + msgid "API reachable, adding method..." msgstr "" @@ -2778,10 +2781,10 @@ msgstr "" msgid "Add %s to list" msgstr "" -msgid "Add 30 days time" +msgid "Add 30 days time (%s)" msgstr "" -msgid "Add 30 days time (%s)" +msgid "Add 90 days time (%s)" msgstr "" msgid "Add DNS server" @@ -2790,6 +2793,9 @@ msgstr "" msgid "Add locations" msgstr "" +msgid "Add time" +msgstr "" + msgid "Adding method..." msgstr "" @@ -2961,6 +2967,9 @@ msgstr "" msgid "Failed to apply patch" msgstr "" +msgid "Failed to load products, please try again" +msgstr "" + msgid "Failed to set to current - API not reachable" msgstr "" @@ -2982,6 +2991,9 @@ msgstr "" msgid "Google Play payment pending" msgstr "" +msgid "Google Play payment pending, this might take some time" +msgstr "" + msgid "Google Play unavailable" msgstr "" @@ -3003,6 +3015,9 @@ msgstr "" msgid "Importing new overrides might replace some previously imported overrides." msgstr "" +msgid "In-app products unavailable, please make sure you have the latest version of Google Play." +msgstr "" + msgid "In-tunnel IPv6" msgstr "" @@ -3021,6 +3036,9 @@ msgstr "" msgid "List name" msgstr "" +msgid "Loading products" +msgstr "" + msgid "Locations" msgstr "" @@ -3030,9 +3048,6 @@ msgstr "" msgid "Makes sure the device is always on the VPN tunnel." msgstr "" -msgid "Manage account" -msgstr "" - msgid "Manage devices" msgstr "" @@ -3177,6 +3192,9 @@ msgstr "" msgid "The \"Current\" method represent which method the app is using to reach the API." msgstr "" +msgid "The app is blocking internet, please disconnect first" +msgstr "" + msgid "The device will be removed from the list and logged out." msgstr "" @@ -3198,6 +3216,9 @@ msgstr "" msgid "This is already set as current" msgstr "" +msgid "Time added" +msgstr "" + msgid "To add locations to a list, press the \"︙\" or long press on a country, city, or server." msgstr "" @@ -3273,7 +3294,7 @@ msgstr "" msgid "View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily." msgstr "" -msgid "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful." +msgid "We are still verifying your purchase, this might take some time. Your time will be added if the verification is successful." msgstr "" msgid "We were unable to start the payment process, please make sure you have the latest version of Google Play." |
