summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt372
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt72
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt207
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt172
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt182
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt64
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt506
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt304
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt219
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt80
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt63
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt155
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt185
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt210
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt96
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt136
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt44
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt44
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt137
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt200
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt128
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt71
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt101
-rw-r--r--android/config/lint-baseline.xml11
-rw-r--r--android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt18
-rw-r--r--android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt1
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt10
-rw-r--r--android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt1
-rw-r--r--android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt4
-rw-r--r--android/lib/resource/src/main/res/drawable/google_pay_primary_logo_logo_svgrepo_com.xml13
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml19
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt4
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=" &lt;path android:fillColor=&quot;#5F6368&quot; android:pathData=&quot;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&quot;/>"
+ 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,