summaryrefslogtreecommitdiffhomepage
path: root/android/app
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-06-09 16:43:14 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-06-09 16:43:14 +0200
commit8b0b5ab45c3e0720797bd381d4b02e70cf4043f9 (patch)
tree4d5d5fc018053cf664be5c41040f8755de07c55d /android/app
parent87e716c551f563b6bf181bcef87a58bee0fb2599 (diff)
parent1c58ad3fc58c1862526d912efc311e06956317fd (diff)
downloadmullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.tar.xz
mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.zip
Merge branch 'implement-payment-screen-with-3-months-droid-1947'
Diffstat (limited to 'android/app')
-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
39 files changed, 2226 insertions, 1904 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)
+ }
}
}