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 /android | |
| parent | 87e716c551f563b6bf181bcef87a58bee0fb2599 (diff) | |
| parent | 1c58ad3fc58c1862526d912efc311e06956317fd (diff) | |
| download | mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.tar.xz mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.zip | |
Merge branch 'implement-payment-screen-with-3-months-droid-1947'
Diffstat (limited to 'android')
67 files changed, 2325 insertions, 2038 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, |
